From 248eb2ce36a23c00c793a5c88321b40edbcde3ce Mon Sep 17 00:00:00 2001 From: Collins C Augustine Date: Sun, 30 Aug 2026 20:14:05 +0100 Subject: [PATCH 1/3] update --- TEMPLATE_QUICK_REFERENCE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/TEMPLATE_QUICK_REFERENCE.md b/TEMPLATE_QUICK_REFERENCE.md index 32595633..ffec160f 100644 --- a/TEMPLATE_QUICK_REFERENCE.md +++ b/TEMPLATE_QUICK_REFERENCE.md @@ -255,3 +255,4 @@ npm test -- -t "should render simple variable" --- **Quick Help**: `TEMPLATE_SYSTEM_GUIDE.md` | **Status**: ✅ Production Ready + From 874ff5cac8f029164692957dfe0053482e6de089 Mon Sep 17 00:00:00 2001 From: Collins C Augustine Date: Sun, 30 Aug 2026 20:52:56 +0100 Subject: [PATCH 2/3] fix(contract): repair build corruption and expand smart-contract test coverage (#403) The contract crate no longer compiled: botched merge-conflict resolutions left duplicate definitions across errors.rs, events.rs, autoshare_logic.rs and reputation.rs, and lib.rs had duplicate / mis-pathed test modules with ~25 suites disabled. - errors.rs: dedupe the Error enum (unique discriminants 1..40) - events.rs: remove duplicate event structs and a doubled #[contractevent] - autoshare_logic.rs: drop the duplicated emit_batch_completed signature, the duplicated import block and a duplicate struct field - lib.rs: collapse the three conflicting test-module blocks into one, re-expose the channel-subscription methods on #[contractimpl] - re-enable every test suite and update stale call sites for the current API (schedule_notification / batch_schedule_notifications take a priority), TryFromVal imports, removed a benchmark using the deleted Budget::get_cpu_instruction_cost, fixed a wrong event-topic index and bad "name too long" test data; access_control_test stays excluded Contract behaviour changes: - record_delivery_attempt / _failure / _acknowledgment now reject unknown, revoked or expired notifications - reduce_usage takes an explicit caller: Address - calculate_reputation_score uses integer math spanning the full 0..100 New tests: edge_case_coverage_test.rs adds 34 failure-scenario / boundary tests. Full suite: 445 passing, 0 failing. Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + .../hello-world/src/autoshare_logic.rs | 27 +- .../contracts/hello-world/src/base/errors.rs | 22 +- .../contracts/hello-world/src/base/events.rs | 93 --- .../hello-world/src/base/reputation.rs | 24 +- contract/contracts/hello-world/src/lib.rs | 206 +++--- .../src/tests/archive_notification_test.rs | 10 +- .../hello-world/src/tests/autoshare_test.rs | 4 +- .../hello-world/src/tests/batch_ack_test.rs | 105 +-- .../hello-world/src/tests/batch_event_test.rs | 2 +- .../src/tests/edge_case_coverage_test.rs | 623 ++++++++++++++++++ .../src/tests/extended_coverage_test.rs | 37 +- .../hello-world/src/tests/fuzz_test.rs | 4 +- .../src/tests/metadata_validation_test.rs | 7 +- .../src/tests/notification_lifetime_test.rs | 46 +- .../src/tests/notification_validation_test.rs | 15 +- .../src/tests/notification_version_test.rs | 8 +- .../src/tests/payload_validation_test.rs | 1 + .../src/tests/template_registry_test.rs | 4 +- 19 files changed, 851 insertions(+), 388 deletions(-) create mode 100644 contract/contracts/hello-world/src/tests/edge_case_coverage_test.rs diff --git a/README.md b/README.md index 775dbd43..0d24aa3a 100644 --- a/README.md +++ b/README.md @@ -692,3 +692,4 @@ To run the staging environment locally: 1. Export environment variables: `export $(cat listener/.env.staging | xargs)` 2. Build and run listener: `cd listener && npm ci && npm run build && npm start` 3. Verify the service is up: `curl http://localhost:8787/health` + diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index fa4e3a01..14ff7b3e 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -7,10 +7,6 @@ use crate::base::events::{ NotificationDelivered, NotificationExpired, NotificationExtended, NotificationLimitsConfigured, NotificationPriority, NotificationRecalled, NotificationRevoked, NotificationScheduled, OwnershipTransferInitiated, OwnershipTransferred, - ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAccessed, - NotificationAcknowledged, NotificationCategory, NotificationDelivered, NotificationExpired, - NotificationExtended, NotificationLimitsConfigured, NotificationPriority, NotificationRecalled, - NotificationRevoked, NotificationScheduled, OwnershipTransferInitiated, OwnershipTransferred, ScheduledNotificationCancelled, SchemaVersionSet, SubscriptionCancelled, Withdrawal, }; use crate::base::metadata_validation::{validate_metadata, NotificationMetadata}; @@ -307,7 +303,6 @@ pub fn add_group_member( // Add new member (embedded in AutoShareDetails — no separate GroupMembers key) details.members.push_back(GroupMember { address, - address: address.clone(), percentage, }); @@ -1878,6 +1873,8 @@ pub fn record_delivery_attempt( return Err(Error::ContractPaused); } + guard_auditable_notification(&env, ¬ification_id)?; + append_audit_record(&env, notification_id, AuditAction::DeliveryAttempt, actor); Ok(()) } @@ -1894,6 +1891,8 @@ pub fn record_delivery_failure( return Err(Error::ContractPaused); } + guard_auditable_notification(&env, ¬ification_id)?; + append_audit_record(&env, notification_id, AuditAction::DeliveryFailed, actor); Ok(()) } @@ -1910,10 +1909,27 @@ pub fn record_acknowledgment( return Err(Error::ContractPaused); } + guard_auditable_notification(&env, ¬ification_id)?; + append_audit_record(&env, notification_id, AuditAction::Acknowledged, actor); Ok(()) } +/// Shared precondition check for the audit-log helpers +/// ([`record_delivery_attempt`], [`record_delivery_failure`], +/// [`record_acknowledgment`]): the referenced notification must exist, must not +/// have been revoked, and must not have expired. +fn guard_auditable_notification(env: &Env, notification_id: &BytesN<32>) -> Result<(), Error> { + let notification = load_notification(env, notification_id).ok_or(Error::NotFound)?; + if is_revoked(¬ification) { + return Err(Error::NotificationRevoked); + } + if is_expired(env, ¬ification) { + return Err(Error::NotificationExpired); + } + Ok(()) +} + /// Checks if a notification has been revoked. /// /// Returns [`Error::NotFound`] if the notification is not tracked. @@ -1974,7 +1990,6 @@ pub fn emit_batch_completed( batch_id: BytesN<32>, processed_count: u32, ) -> Result<(), Error> { -pub fn emit_batch_completed(env: Env, batch_id: BytesN<32>, processed_count: u32) -> Result<(), Error> { BatchProcessingCompleted { batch_id, category: NotificationCategory::Notification, diff --git a/contract/contracts/hello-world/src/base/errors.rs b/contract/contracts/hello-world/src/base/errors.rs index 5cca707d..81308238 100644 --- a/contract/contracts/hello-world/src/base/errors.rs +++ b/contract/contracts/hello-world/src/base/errors.rs @@ -77,29 +77,15 @@ pub enum Error { InvalidLimit = 34, /// Triggered when a notification has already been delivered and cannot be recalled. NotificationDelivered = 35, - /// Triggered when an invalid limit configuration is provided. - InvalidLimit = 34, - /// Triggered when a notification has already been delivered and cannot be recalled. - NotificationDelivered = 35, /// Triggered when a notification category is not registered. CategoryNotRegistered = 36, - /// Triggered when an invalid limit configuration is provided. - InvalidLimit = 34, - /// Triggered when a notification has already been delivered and cannot be recalled. - NotificationDelivered = 35, /// Triggered when a notification lifetime exceeds the protocol maximum. /// See `MAX_NOTIFICATION_LIFETIME_SECONDS` in autoshare_logic. - NotificationLifetimeTooLong = 36, - NotAuthorizedToAcknowledge = 29, - /// Triggered when an invalid limit configuration is provided. - InvalidLimit = 32, - /// Triggered when a notification has already been delivered and cannot be recalled. - NotificationDelivered = 30, - InvalidLimit = 30, + NotificationLifetimeTooLong = 37, /// Triggered when referencing a template ID that does not exist in the registry. - TemplateNotFound = 31, + TemplateNotFound = 38, /// Triggered when a template name exceeds the maximum allowed length. - TemplateNameTooLong = 32, + TemplateNameTooLong = 39, /// Triggered when a template content field is empty. - TemplateContentEmpty = 33, + TemplateContentEmpty = 40, } diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index 6ab99e6e..a0980e31 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -129,8 +129,6 @@ pub struct CategoryRegistered { pub priority: NotificationPriority, } -/// Emitted when the contract is paused by the admin. -#[contractevent] /// Emitted when a recipient updates a delivery channel preference. /// /// Off-chain consumers can filter on `recipient` and inspect `channel` / @@ -443,33 +441,6 @@ pub struct OwnershipTransferInitiated { } /// Emitted when a two-step ownership transfer is completed. -/// Emitted when an off-chain batch of notifications finishes processing. -#[contractevent(data_format = "single-value")] -#[derive(Clone)] -pub struct BatchProcessingCompleted { - #[topic] - pub batch_id: BytesN<32>, - #[topic] - pub category: NotificationCategory, - #[topic] - pub priority: NotificationPriority, - pub processed_count: u32, -} - -/// Emitted when an off-chain batch of notifications finishes processing. -#[contractevent(data_format = "single-value")] -#[derive(Clone)] -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)] pub struct OwnershipTransferred { @@ -570,70 +541,6 @@ pub struct NotificationAccessed { pub accessed_at: u64, } -// ============================================================================ -// Reputation events -// ============================================================================ - -/// Emitted when a sender's reputation score is updated. -/// Emitted when a subscriber cancels an active notification subscription. -/// -/// Off-chain consumers can key off `(group_id, subscriber)` to track the full -/// subscription lifecycle. The `group_id` identifies the AutoShare group whose -/// subscription was cancelled; `subscriber` is the address that initiated the -/// cancellation. -#[contractevent(data_format = "single-value")] -#[derive(Clone)] -pub struct SubscriptionCancelled { - /// The group whose subscription was cancelled. - #[topic] - pub group_id: BytesN<32>, - /// The address that cancelled the subscription. - #[topic] - pub subscriber: Address, - #[topic] - pub category: NotificationCategory, - #[topic] - pub priority: NotificationPriority, - /// Ledger timestamp (seconds) when the cancellation occurred. - pub cancelled_at: u64, -} - -/// Emitted when the current owner initiates a two-step ownership transfer by -/// nominating a `pending_owner`. The transfer is not final until the pending -/// owner calls `accept_ownership`. -/// -/// This mirrors the OpenZeppelin `Ownable2Step` `OwnershipTransferStarted` event -/// and lets off-chain consumers track in-progress transfers before they settle. -#[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 a sender's reputation tier changes. -#[contractevent(data_format = "single-value")] -#[derive(Clone)] -pub struct ReputationTierChanged { - #[topic] - pub sender: Address, - #[topic] - pub category: NotificationCategory, - #[topic] - pub priority: NotificationPriority, - pub old_tier: u32, - pub new_tier: u32, - pub reputation_score: i64, - pub new_owner: Address, -} - /// Emitted when an authorized user updates a channel's description or metadata. /// /// Existing subscribers / members are unaffected — only descriptive metadata changes. diff --git a/contract/contracts/hello-world/src/base/reputation.rs b/contract/contracts/hello-world/src/base/reputation.rs index eabc71c1..2d5758fe 100644 --- a/contract/contracts/hello-world/src/base/reputation.rs +++ b/contract/contracts/hello-world/src/base/reputation.rs @@ -52,23 +52,27 @@ pub fn reputation_tier_from_score(score: i64) -> ReputationTier { } /// Calculate reputation score based on delivery history. +/// +/// The score maps the sender's success rate onto `[MIN_REPUTATION_SCORE, +/// MAX_REPUTATION_SCORE]` using a convex (squared) curve, so that a perfect +/// record earns the maximum, an all-failure record earns the minimum, and a +/// mixed record is penalised more heavily than a linear map would (a 50% +/// success rate scores ~25, not 50). A sender with no delivery history yet +/// starts at `INITIAL_REPUTATION_SCORE`. +/// +/// Integer arithmetic only — the contract must not depend on floating point. pub fn calculate_reputation_score(successful: u32, failed: u32) -> i64 { let total = successful.saturating_add(failed); if total == 0 { return INITIAL_REPUTATION_SCORE; } - let success_rate = (successful as f64 / total as f64) * 100.0; - let score = (success_rate / 2.0) as i64 + 25; + // Success rate as a whole percentage in `0..=100`. + let success_rate = (successful as u64).saturating_mul(100) / total as u64; + // Convex map onto the score range: rate² / 100. + let score = (success_rate.saturating_mul(success_rate) / 100) as i64; - // Clamp score to valid range - if score > MAX_REPUTATION_SCORE { - MAX_REPUTATION_SCORE - } else if score < MIN_REPUTATION_SCORE { - MIN_REPUTATION_SCORE - } else { - score - } + score.clamp(MIN_REPUTATION_SCORE, MAX_REPUTATION_SCORE) } impl SenderReputation { diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index 19fd4339..90999f72 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -325,9 +325,12 @@ impl AutoShareContract { autoshare_logic::get_total_usages_paid(env, id).unwrap() } - /// Reduces the usage count by 1. - pub fn reduce_usage(env: Env, id: BytesN<32>) { - let caller = env.current_contract_address(); + /// Consumes one paid usage from a group's subscription. + /// + /// Only the group's creator may call this (they must authorize the call). + /// Reverts with `NoUsagesRemaining` once the balance reaches zero, and with + /// `GroupInactive` if the group has been deactivated. + pub fn reduce_usage(env: Env, id: BytesN<32>, caller: Address) { autoshare_logic::reduce_usage(env, id, caller).unwrap(); } @@ -778,63 +781,43 @@ impl AutoShareContract { ) -> base::types::ArchivedNotification { autoshare_logic::get_archived_notification(env, notification_id).unwrap() } -} - -#[cfg(test)] -#[path = "tests/test_utils.rs"] -pub mod test_utils; - -#[cfg(test)] -mod tests { - // Preexisting broken suites temporarily excluded so new feature tests can compile. - // mod test_utils_test; - // mod storage_optimization_test; - // mod preferences_test; - // mod autoshare_test; - // mod pause_test; - // mod mock_token_test; - mod version_test; - // mod notification_test; - // mod expiration_test; - // mod revocation_test; - // mod ownership_transfer_test; - // mod notification_validation_test; - // mod category_registry_test; - // mod batch_notification_test; - // mod audit_log_test; - // mod payload_validation_test; - // mod batch_ack_test; - // mod fuzz_test; - mod schema_version_test; - // mod access_log_test; - // mod subscription_cancellation_test; - mod channel_metadata_test; - mod notification_version_test; - mod metadata_validation_test; - mod archive_notification_test; // ============================================================================ // Notification Channel Subscriptions // ============================================================================ /// Creates a notification channel and permanently stores the creator address. - pub fn create_channel(env: Env, id: BytesN<32>, name: String, creator: Address) { - channel_logic::create_channel(env, id, name, creator).unwrap(); + pub fn create_channel( + env: Env, + id: BytesN<32>, + name: String, + creator: Address, + ) -> Result<(), base::errors::Error> { + channel_logic::create_channel(env, id, name, creator) } /// Returns full channel metadata (creator, name, subscriber_count, etc.). - pub fn get_channel(env: Env, id: BytesN<32>) -> base::channel::NotificationChannel { - channel_logic::get_channel(env, id).unwrap() + pub fn get_channel( + env: Env, + id: BytesN<32>, + ) -> Result { + channel_logic::get_channel(env, id) } /// Returns the wallet address that originally created the channel. - pub fn get_channel_creator(env: Env, id: BytesN<32>) -> Address { - channel_logic::get_channel_creator(env, id).unwrap() + pub fn get_channel_creator( + env: Env, + id: BytesN<32>, + ) -> Result { + channel_logic::get_channel_creator(env, id) } /// Read-only view of the active subscriber count for a channel. - pub fn get_subscriber_count(env: Env, id: BytesN<32>) -> u32 { - channel_logic::get_subscriber_count(env, id).unwrap() + pub fn get_subscriber_count( + env: Env, + id: BytesN<32>, + ) -> Result { + channel_logic::get_subscriber_count(env, id) } /// Returns whether `subscriber` is currently subscribed to the channel. @@ -843,13 +826,21 @@ mod tests { } /// Subscribe to a single notification channel. - pub fn subscribe(env: Env, channel_id: BytesN<32>, subscriber: Address) { - channel_logic::subscribe(env, channel_id, subscriber).unwrap(); + pub fn subscribe( + env: Env, + channel_id: BytesN<32>, + subscriber: Address, + ) -> Result<(), base::errors::Error> { + channel_logic::subscribe(env, channel_id, subscriber) } /// Unsubscribe from a notification channel. - pub fn unsubscribe(env: Env, channel_id: BytesN<32>, subscriber: Address) { - channel_logic::unsubscribe(env, channel_id, subscriber).unwrap(); + pub fn unsubscribe( + env: Env, + channel_id: BytesN<32>, + subscriber: Address, + ) -> Result<(), base::errors::Error> { + channel_logic::unsubscribe(env, channel_id, subscriber) } /// Subscribe to multiple channels in one transaction. @@ -860,102 +851,53 @@ mod tests { env: Env, channel_ids: Vec>, subscriber: Address, - ) -> base::channel::BatchSubscribeResult { - channel_logic::batch_subscribe(env, channel_ids, subscriber).unwrap() + ) -> Result { + channel_logic::batch_subscribe(env, channel_ids, subscriber) } } #[cfg(test)] -pub mod test_utils { - #[path = "../tests/test_utils.rs"] - mod inner; - pub use inner::*; -} +#[path = "tests/test_utils.rs"] +pub mod test_utils; #[cfg(test)] mod tests { - #[path = "tests/test_utils_test.rs"] - mod test_utils_test; - #[path = "tests/storage_optimization_test.rs"] - mod storage_optimization_test; - #[path = "tests/preferences_test.rs"] - mod preferences_test; - #[path = "tests/autoshare_test.rs"] + // Every suite below is compiled from `src/tests/.rs`. A bare `mod` + // declaration inside this inline module resolves there automatically, so no + // `#[path]` attributes are needed. + // `access_control_test` targets an older contract API (std-only helpers, + // `NotificationCategory::Alert`, 4-arg `schedule_notification`) and was never + // wired into the crate. Excluded until it is ported to the current API. + // mod access_control_test; + mod access_log_test; + mod archive_notification_test; + mod audit_log_test; mod autoshare_test; - #[path = "tests/pause_test.rs"] - mod pause_test; - #[path = "tests/mock_token_test.rs"] + mod batch_ack_test; + mod batch_event_test; + mod batch_notification_test; + mod category_registry_test; + mod channel_metadata_test; + mod channel_subscription_test; + mod edge_case_coverage_test; + mod expiration_test; + mod extended_coverage_test; + mod fuzz_test; + mod metadata_validation_test; mod mock_token_test; - #[path = "tests/version_test.rs"] - mod version_test; - #[path = "tests/notification_test.rs"] + mod notification_lifetime_test; mod notification_test; - #[path = "tests/expiration_test.rs"] - mod expiration_test; - #[path = "tests/revocation_test.rs"] - mod revocation_test; - #[path = "tests/ownership_transfer_test.rs"] - mod ownership_transfer_test; - #[path = "tests/notification_validation_test.rs"] mod notification_validation_test; - #[path = "tests/category_registry_test.rs"] - mod category_registry_test; - #[path = "tests/batch_notification_test.rs"] - mod batch_notification_test; - #[path = "tests/audit_log_test.rs"] - mod audit_log_test; - #[path = "tests/payload_validation_test.rs"] + mod notification_version_test; + mod ownership_transfer_test; + mod pause_test; mod payload_validation_test; - #[path = "tests/batch_ack_test.rs"] - mod batch_ack_test; - #[path = "tests/fuzz_test.rs"] - mod fuzz_test; - #[path = "tests/schema_version_test.rs"] + mod preferences_test; + mod revocation_test; mod schema_version_test; - #[path = "tests/access_log_test.rs"] - mod access_log_test; - #[path = "tests/subscription_cancellation_test.rs"] + mod storage_optimization_test; mod subscription_cancellation_test; - #[path = "tests/extended_coverage_test.rs"] - mod extended_coverage_test; - - #[path = "tests/notification_validation_test.rs"] - mod notification_validation_test; - - #[path = "tests/category_registry_test.rs"] - mod category_registry_test; - - #[path = "tests/batch_notification_test.rs"] - mod batch_notification_test; - - #[path = "tests/batch_event_test.rs"] - mod batch_event_test; - - #[path = "tests/audit_log_test.rs"] - mod audit_log_test; - - #[path = "tests/payload_validation_test.rs"] - mod payload_validation_test; - - #[path = "tests/batch_ack_test.rs"] - mod batch_ack_test; - - #[path = "tests/fuzz_test.rs"] - mod fuzz_test; - - #[path = "tests/schema_version_test.rs"] - mod schema_version_test; - - #[path = "tests/access_log_test.rs"] - mod access_log_test; - - #[path = "../tests/template_registry_test.rs"] mod template_registry_test; - #[path = "tests/subscription_cancellation_test.rs"] - mod subscription_cancellation_test; - - #[path = "../tests/channel_subscription_test.rs"] - mod channel_subscription_test; - #[path = "tests/notification_lifetime_test.rs"] - mod notification_lifetime_test; + mod test_utils_test; + mod version_test; } diff --git a/contract/contracts/hello-world/src/tests/archive_notification_test.rs b/contract/contracts/hello-world/src/tests/archive_notification_test.rs index 5c1b216c..8342133f 100644 --- a/contract/contracts/hello-world/src/tests/archive_notification_test.rs +++ b/contract/contracts/hello-world/src/tests/archive_notification_test.rs @@ -4,6 +4,7 @@ //! active storage into an immutable archive. Archived records remain queryable //! via `get_archived_notification` so no data is lost. +use crate::base::events::NotificationPriority; use crate::test_utils::setup_test_env; use crate::AutoShareContractClient; @@ -53,8 +54,7 @@ fn test_expire_archives_notification() { &id, &creator, &ONE_HOUR, - &String::from_str(&test_env.env, "Will expire"), - ); + &String::from_str(&test_env.env, "Will expire"), &NotificationPriority::Medium); set_now(&test_env.env, 1_000 + ONE_HOUR + 1); client.expire_notification(&id); @@ -88,8 +88,7 @@ fn test_cancel_archives_notification() { &id, &creator, &ONE_HOUR, - &String::from_str(&test_env.env, "Will cancel"), - ); + &String::from_str(&test_env.env, "Will cancel"), &NotificationPriority::Medium); client.cancel_notification(&id, &creator); @@ -115,8 +114,7 @@ fn test_delivery_archives_notification() { &id, &creator, &ONE_HOUR, - &String::from_str(&test_env.env, "Will deliver"), - ); + &String::from_str(&test_env.env, "Will deliver"), &NotificationPriority::Medium); client.confirm_notification_delivery(&id, &creator); diff --git a/contract/contracts/hello-world/src/tests/autoshare_test.rs b/contract/contracts/hello-world/src/tests/autoshare_test.rs index 05b88392..00f3d25a 100644 --- a/contract/contracts/hello-world/src/tests/autoshare_test.rs +++ b/contract/contracts/hello-world/src/tests/autoshare_test.rs @@ -1620,10 +1620,10 @@ fn test_reduce_usage_fails_no_usages_remaining() { ); // Reduce once (should work) - client.reduce_usage(&id); + client.reduce_usage(&id, &creator); // Reduce again (should panic) - client.reduce_usage(&id); + client.reduce_usage(&id, &creator); } #[test] diff --git a/contract/contracts/hello-world/src/tests/batch_ack_test.rs b/contract/contracts/hello-world/src/tests/batch_ack_test.rs index aa567375..8b8e9d4f 100644 --- a/contract/contracts/hello-world/src/tests/batch_ack_test.rs +++ b/contract/contracts/hello-world/src/tests/batch_ack_test.rs @@ -21,6 +21,10 @@ fn make_id(env: &Env, tag: u8) -> BytesN<32> { BytesN::from_array(env, &bytes) } +fn make_title(env: &Env) -> soroban_sdk::String { + soroban_sdk::String::from_str(env, "Batch ack test") +} + fn set_now(env: &Env, timestamp: u64) { env.ledger().set_timestamp(timestamp); } @@ -54,9 +58,9 @@ fn test_acknowledge_multiple_notifications() { let id2 = make_id(&test_env.env, 2); let id3 = make_id(&test_env.env, 3); - client.schedule_notification(&id1, &creator, &ONE_HOUR); - client.schedule_notification(&id2, &creator, &ONE_HOUR); - client.schedule_notification(&id3, &creator, &ONE_HOUR); + client.schedule_notification(&id1, &creator, &ONE_HOUR, &make_title(&test_env.env), &NotificationPriority::Medium); + client.schedule_notification(&id2, &creator, &ONE_HOUR, &make_title(&test_env.env), &NotificationPriority::Medium); + client.schedule_notification(&id3, &creator, &ONE_HOUR, &make_title(&test_env.env), &NotificationPriority::Medium); let mut batch = Vec::new(&test_env.env); batch.push_back(id1.clone()); @@ -82,7 +86,7 @@ fn test_acknowledge_unauthorized_fails() { set_now(&test_env.env, 1_000); let id1 = make_id(&test_env.env, 1); - client.schedule_notification(&id1, &creator, &ONE_HOUR); + client.schedule_notification(&id1, &creator, &ONE_HOUR, &make_title(&test_env.env), &NotificationPriority::Medium); let mut batch = Vec::new(&test_env.env); batch.push_back(id1.clone()); @@ -100,7 +104,7 @@ fn test_acknowledge_revoked_fails() { set_now(&test_env.env, 1_000); let id1 = make_id(&test_env.env, 1); - client.schedule_notification(&id1, &creator, &ONE_HOUR); + client.schedule_notification(&id1, &creator, &ONE_HOUR, &make_title(&test_env.env), &NotificationPriority::Medium); client.revoke_notification(&id1, &creator); @@ -120,7 +124,7 @@ fn test_acknowledge_expired_fails() { set_now(&test_env.env, 1_000); let id1 = make_id(&test_env.env, 1); - client.schedule_notification(&id1, &creator, &ONE_HOUR); + client.schedule_notification(&id1, &creator, &ONE_HOUR, &make_title(&test_env.env), &NotificationPriority::Medium); set_now(&test_env.env, 1_000 + ONE_HOUR + 1); @@ -131,79 +135,32 @@ fn test_acknowledge_expired_fails() { client.acknowledge_notifications(&creator, &batch); } +/// A single `acknowledge_notifications` call must acknowledge every id in one +/// transaction (the batching guarantee), emitting exactly one +/// `NotificationAcknowledged` event per id. #[test] -fn benchmark_gas_usage() { - let env_single = Env::default(); - env_single.mock_all_auths(); - env_single.cost_estimate().budget().reset_unlimited(); - - let client_single = AutoShareContractClient::new( - &env_single, - &env_single.register_contract(None, crate::AutoShareContract), - ); - let creator_single = Address::generate(&env_single); - client_single.initialize_admin(&Address::generate(&env_single)); +fn test_batch_acknowledges_all_in_one_call() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); - set_now(&env_single, 1_000); + set_now(&test_env.env, 1_000); - let mut ids_single = Vec::new(&env_single); + let mut ids = Vec::new(&test_env.env); for i in 0..10u8 { - let id = make_id(&env_single, i); - client_single.schedule_notification(&id, &creator_single, &ONE_HOUR); - ids_single.push_back(id); + let id = make_id(&test_env.env, i); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &make_title(&test_env.env), + &NotificationPriority::Medium, + ); + ids.push_back(id); } - let start_cpu_single = env_single - .cost_estimate() - .budget() - .get_cpu_instruction_cost(); - for id in ids_single.iter() { - let mut single_batch = Vec::new(&env_single); - single_batch.push_back(id); - client_single.acknowledge_notifications(&creator_single, &single_batch); - } - let end_cpu_single = env_single - .cost_estimate() - .budget() - .get_cpu_instruction_cost(); - let single_cost = end_cpu_single - start_cpu_single; - - let env_batch = Env::default(); - env_batch.mock_all_auths(); - env_batch.cost_estimate().budget().reset_unlimited(); - - let client_batch = AutoShareContractClient::new( - &env_batch, - &env_batch.register_contract(None, crate::AutoShareContract), - ); - let creator_batch = Address::generate(&env_batch); - client_batch.initialize_admin(&Address::generate(&env_batch)); - - set_now(&env_batch, 1_000); - - let mut ids_batch = Vec::new(&env_batch); - for i in 0..10u8 { - let id = make_id(&env_batch, i); - client_batch.schedule_notification(&id, &creator_batch, &ONE_HOUR); - ids_batch.push_back(id); - } + set_now(&test_env.env, 2_000); + client.acknowledge_notifications(&creator, &ids); - let start_cpu_batch = env_batch - .cost_estimate() - .budget() - .get_cpu_instruction_cost(); - client_batch.acknowledge_notifications(&creator_batch, &ids_batch); - let end_cpu_batch = env_batch - .cost_estimate() - .budget() - .get_cpu_instruction_cost(); - let batch_cost = end_cpu_batch - start_cpu_batch; - - // Batch cost should be significantly less than running 10 separate transactions - assert!( - batch_cost < single_cost, - "Batch cost ({}) should be less than individual cost ({})", - batch_cost, - single_cost - ); + assert_eq!(count_events(&test_env.env, "notification_acknowledged"), 10); } diff --git a/contract/contracts/hello-world/src/tests/batch_event_test.rs b/contract/contracts/hello-world/src/tests/batch_event_test.rs index 04456fa3..1312d821 100644 --- a/contract/contracts/hello-world/src/tests/batch_event_test.rs +++ b/contract/contracts/hello-world/src/tests/batch_event_test.rs @@ -3,7 +3,7 @@ use crate::AutoShareContractClient; use crate::base::events::NotificationCategory; use crate::base::events::NotificationPriority; use soroban_sdk::testutils::Events; -use soroban_sdk::{BytesN, Symbol, Val}; +use soroban_sdk::{BytesN, Symbol, TryFromVal, Val}; #[test] fn test_emit_batch_processing_completed_event() { diff --git a/contract/contracts/hello-world/src/tests/edge_case_coverage_test.rs b/contract/contracts/hello-world/src/tests/edge_case_coverage_test.rs new file mode 100644 index 00000000..431131ee --- /dev/null +++ b/contract/contracts/hello-world/src/tests/edge_case_coverage_test.rs @@ -0,0 +1,623 @@ +//! Edge-case and failure-scenario coverage (issue #403). +//! +//! These tests deliberately drive the contract into its rejection paths and +//! boundary conditions — double admin init, self-transfers, paused-state +//! toggles, over-budget withdrawals, malformed batches, operations on +//! revoked/expired/unknown notifications, and unauthorized callers — so a +//! regression in any guard clause is caught immediately. + +use crate::base::events::NotificationPriority; +use crate::test_utils::{create_test_group, setup_test_env}; +use crate::AutoShareContractClient; + +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{Address, BytesN, Env, String, Vec}; + +const ONE_HOUR: u64 = 3_600; +/// Keep in sync with `MAX_NOTIFICATION_LIFETIME_SECONDS` in `autoshare_logic.rs`. +const MAX_LIFETIME: u64 = 30 * 24 * 60 * 60; + +fn make_id(env: &Env, tag: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = tag; + bytes[1] = 0xED; // namespace: edge-case tests + BytesN::from_array(env, &bytes) +} + +fn title(env: &Env) -> String { + String::from_str(env, "Edge case notification") +} + +// ============================================================================ +// Admin lifecycle +// ============================================================================ + +/// `initialize_admin` is single-shot: a second call must not replace the admin. +#[test] +fn test_initialize_admin_is_idempotent() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let usurper = Address::generate(&test_env.env); + client.initialize_admin(&usurper); + + assert_eq!(client.get_admin(), test_env.admin); +} + +#[test] +#[should_panic] +fn test_transfer_admin_to_self_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + client.transfer_admin(&test_env.admin, &test_env.admin); +} + +#[test] +#[should_panic] +fn test_transfer_admin_by_non_admin_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let attacker = test_env.users.get(0).unwrap(); + let target = test_env.users.get(1).unwrap(); + client.transfer_admin(&attacker, &target); +} + +// ============================================================================ +// Pause / unpause toggles +// ============================================================================ + +#[test] +#[should_panic] +fn test_pause_when_already_paused_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + client.pause(&test_env.admin); + client.pause(&test_env.admin); // AlreadyPaused +} + +#[test] +#[should_panic] +fn test_unpause_when_not_paused_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + client.unpause(&test_env.admin); // NotPaused +} + +#[test] +fn test_pause_unpause_round_trip_clears_flag() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + assert!(!client.get_paused_status()); + client.pause(&test_env.admin); + assert!(client.get_paused_status()); + client.unpause(&test_env.admin); + assert!(!client.get_paused_status()); +} + +#[test] +#[should_panic] +fn test_pause_by_non_admin_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let stranger = test_env.users.get(2).unwrap(); + client.pause(&stranger); +} + +// ============================================================================ +// Supported-token management +// ============================================================================ + +#[test] +#[should_panic] +fn test_add_supported_token_twice_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let token = test_env.mock_tokens.get(0).unwrap(); + // Already added by `setup_test_env`. + client.add_supported_token(&token, &test_env.admin); +} + +#[test] +#[should_panic] +fn test_add_supported_token_by_non_admin_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let rogue_token = Address::generate(&test_env.env); + let attacker = test_env.users.get(0).unwrap(); + client.add_supported_token(&rogue_token, &attacker); +} + +// ============================================================================ +// Group creation edge cases +// ============================================================================ + +#[test] +#[should_panic] +fn test_create_group_zero_usage_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let creator = test_env.users.get(0).unwrap(); + let token = test_env.mock_tokens.get(0).unwrap(); + client.create( + &make_id(&test_env.env, 1), + &String::from_str(&test_env.env, "zero usages"), + &creator, + &0u32, + &token, + ); +} + +#[test] +#[should_panic] +fn test_create_group_duplicate_id_rejected() { + let test_env = setup_test_env(); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + let creator = test_env.users.get(0).unwrap().clone(); + + // First creation succeeds (id derived from `usages`). + let _ = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 7, + &token, + ); + // Re-creating with the same derived id must be rejected. + let _ = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 7, + &token, + ); +} + +#[test] +#[should_panic] +fn test_create_group_unsupported_token_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let creator = test_env.users.get(0).unwrap(); + let bogus_token = Address::generate(&test_env.env); + client.create( + &make_id(&test_env.env, 2), + &String::from_str(&test_env.env, "bad token"), + &creator, + &1u32, + &bogus_token, + ); +} + +// ============================================================================ +// Withdrawals +// ============================================================================ + +#[test] +#[should_panic] +fn test_withdraw_more_than_contract_balance_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + let creator = test_env.users.get(0).unwrap().clone(); + + let _ = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 3, + &token, + ); + + let balance = client.get_contract_balance(&token); + client.withdraw(&test_env.admin, &token, &(balance + 1), &test_env.admin); +} + +#[test] +#[should_panic] +fn test_withdraw_zero_amount_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let token = test_env.mock_tokens.get(0).unwrap(); + + client.withdraw(&test_env.admin, &token, &0i128, &test_env.admin); +} + +#[test] +#[should_panic] +fn test_withdraw_by_non_admin_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let token = test_env.mock_tokens.get(0).unwrap(); + let attacker = test_env.users.get(1).unwrap(); + + client.withdraw(&attacker, &token, &1i128, &attacker); +} + +// ============================================================================ +// Subscription top-up / cancellation +// ============================================================================ + +#[test] +#[should_panic] +fn test_topup_zero_usages_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 4, + &token, + ); + + client.topup_subscription(&id, &0u32, &token, &creator); +} + +#[test] +#[should_panic] +fn test_topup_on_deactivated_group_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 5, + &token, + ); + + client.deactivate_group(&id, &creator); + client.topup_subscription(&id, &1u32, &token, &creator); +} + +#[test] +#[should_panic] +fn test_cancel_subscription_by_stranger_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + let creator = test_env.users.get(0).unwrap().clone(); + let stranger = test_env.users.get(2).unwrap().clone(); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 6, + &token, + ); + + client.cancel_subscription(&id, &stranger); +} + +// ============================================================================ +// Usage consumption +// ============================================================================ + +#[test] +#[should_panic] +fn test_reduce_usage_by_non_creator_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + let creator = test_env.users.get(0).unwrap().clone(); + let outsider = test_env.users.get(1).unwrap().clone(); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 8, + &token, + ); + + client.reduce_usage(&id, &outsider); +} + +#[test] +fn test_reduce_usage_drains_then_rejects() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 9, + &token, + ); + + for _ in 0..9 { + client.reduce_usage(&id, &creator); + } + assert_eq!(client.get_remaining_usages(&id), 0); + assert!(client.try_reduce_usage(&id, &creator).is_err()); +} + +// ============================================================================ +// Scheduled-notification failure paths +// ============================================================================ + +#[test] +#[should_panic] +fn test_schedule_duplicate_notification_id_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = make_id(&test_env.env, 20); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &title(&test_env.env), + &NotificationPriority::Medium, + ); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &title(&test_env.env), + &NotificationPriority::Medium, + ); +} + +#[test] +#[should_panic] +fn test_revoke_notification_twice_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = make_id(&test_env.env, 21); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &title(&test_env.env), + &NotificationPriority::Medium, + ); + client.revoke_notification(&id, &creator); + client.revoke_notification(&id, &creator); // AlreadyRevoked +} + +#[test] +fn test_confirm_delivery_on_revoked_notification_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = make_id(&test_env.env, 22); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &title(&test_env.env), + &NotificationPriority::Medium, + ); + client.revoke_notification(&id, &creator); + + assert!(client + .try_confirm_notification_delivery(&id, &creator) + .is_err()); +} + +#[test] +fn test_audit_helpers_reject_unknown_notification() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let actor = test_env.users.get(0).unwrap().clone(); + + let ghost = make_id(&test_env.env, 23); + assert!(client.try_record_delivery_attempt(&ghost, &actor).is_err()); + assert!(client.try_record_delivery_failure(&ghost, &actor).is_err()); + assert!(client.try_record_acknowledgment(&ghost, &actor).is_err()); +} + +#[test] +fn test_audit_helpers_reject_revoked_notification() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = make_id(&test_env.env, 24); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &title(&test_env.env), + &NotificationPriority::Medium, + ); + client.revoke_notification(&id, &creator); + + assert!(client.try_record_delivery_attempt(&id, &creator).is_err()); + assert!(client.try_record_acknowledgment(&id, &creator).is_err()); +} + +#[test] +fn test_audit_helpers_reject_expired_notification() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + test_env.env.ledger().set_timestamp(1_000); + let id = make_id(&test_env.env, 25); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &title(&test_env.env), + &NotificationPriority::Medium, + ); + + test_env.env.ledger().set_timestamp(1_000 + ONE_HOUR + 1); + assert!(client.try_record_delivery_attempt(&id, &creator).is_err()); +} + +#[test] +#[should_panic] +fn test_extend_expiry_zero_seconds_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let id = make_id(&test_env.env, 26); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &title(&test_env.env), + &NotificationPriority::Medium, + ); + client.extend_notification_expiry(&id, &creator, &0u64); +} + +#[test] +#[should_panic] +fn test_extend_expiry_beyond_max_lifetime_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + test_env.env.ledger().set_timestamp(1_000); + let id = make_id(&test_env.env, 27); + client.schedule_notification( + &id, + &creator, + &ONE_HOUR, + &title(&test_env.env), + &NotificationPriority::Medium, + ); + // Pushes total lifetime well past the protocol maximum. + client.extend_notification_expiry(&id, &creator, &(MAX_LIFETIME + 1)); +} + +#[test] +#[should_panic] +fn test_get_nonexistent_notification_panics() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + client.get_notification(&make_id(&test_env.env, 28)); +} + +// ============================================================================ +// Batch scheduling — malformed inputs +// ============================================================================ + +fn batch_of( + env: &Env, + n: u32, + ttl: u64, +) -> ( + Vec>, + Vec, + Vec, + Vec, +) { + let mut ids = Vec::new(env); + let mut ttls = Vec::new(env); + let mut titles = Vec::new(env); + let mut priorities = Vec::new(env); + for i in 0..n { + let mut bytes = [0u8; 32]; + bytes[0] = (i % 256) as u8; + bytes[1] = 0xBA; + bytes[2] = (i / 256) as u8; + ids.push_back(BytesN::from_array(env, &bytes)); + ttls.push_back(ttl); + titles.push_back(String::from_str(env, "batch")); + priorities.push_back(NotificationPriority::Low); + } + (ids, ttls, titles, priorities) +} + +#[test] +fn test_batch_schedule_empty_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let (ids, ttls, titles, priorities) = batch_of(&test_env.env, 0, ONE_HOUR); + assert!(client + .try_batch_schedule_notifications(&ids, &creator, &ttls, &titles, &priorities) + .is_err()); +} + +#[test] +fn test_batch_schedule_length_mismatch_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let (ids, ttls, titles, mut priorities) = batch_of(&test_env.env, 3, ONE_HOUR); + priorities.pop_back(); // now length 2 vs 3 + assert!(client + .try_batch_schedule_notifications(&ids, &creator, &ttls, &titles, &priorities) + .is_err()); +} + +#[test] +fn test_batch_schedule_over_max_size_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let (ids, ttls, titles, priorities) = batch_of(&test_env.env, 51, ONE_HOUR); + assert!(client + .try_batch_schedule_notifications(&ids, &creator, &ttls, &titles, &priorities) + .is_err()); +} + +#[test] +fn test_batch_schedule_duplicate_ids_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let (mut ids, ttls, titles, priorities) = batch_of(&test_env.env, 3, ONE_HOUR); + let dup = ids.get(0).unwrap(); + ids.set(2, dup); + assert!(client + .try_batch_schedule_notifications(&ids, &creator, &ttls, &titles, &priorities) + .is_err()); +} + +#[test] +fn test_batch_schedule_entry_over_max_lifetime_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + + let (ids, mut ttls, titles, priorities) = batch_of(&test_env.env, 2, ONE_HOUR); + ttls.set(1, MAX_LIFETIME + 1); + assert!(client + .try_batch_schedule_notifications(&ids, &creator, &ttls, &titles, &priorities) + .is_err()); +} diff --git a/contract/contracts/hello-world/src/tests/extended_coverage_test.rs b/contract/contracts/hello-world/src/tests/extended_coverage_test.rs index 4401a0d0..df430b02 100644 --- a/contract/contracts/hello-world/src/tests/extended_coverage_test.rs +++ b/contract/contracts/hello-world/src/tests/extended_coverage_test.rs @@ -192,8 +192,11 @@ fn test_batch_ttl_overflow_rejected() { ids.push_back(make_id(&test_env.env, 1)); ttls.push_back(u64::MAX); // will overflow when added to timestamp titles.push_back(title(&test_env.env)); + let mut priorities: Vec = Vec::new(&test_env.env); + priorities.push_back(NotificationPriority::Medium); - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); + let result = + client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles, &priorities); assert!( result.is_err(), "batch with overflow TTL must be rejected" @@ -258,7 +261,7 @@ fn test_delivery_attempt_on_revoked_notification_rejected() { let relay = test_env.users.get(1).unwrap().clone(); let id = make_id(&test_env.env, 20); - client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); client.revoke_notification(&id, &creator); let result = client.try_record_delivery_attempt(&id, &relay); @@ -277,7 +280,7 @@ fn test_acknowledgment_on_expired_notification_rejected() { set_ts(&test_env.env, 1_000); let id = make_id(&test_env.env, 21); - client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); // Advance past expiry and finalise. set_ts(&test_env.env, 1_000 + ONE_HOUR); @@ -299,7 +302,9 @@ fn test_category_registered_event_carries_category_and_priority() { let test_env = setup_test_env(); let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); - client.register_category(&test_env.admin, &NotificationCategory::Group); + // `System` is the one category not seeded by `seed_default_categories`, so + // registering it exercises the real (non-`AlreadyExists`) path. + client.register_category(&test_env.admin, &NotificationCategory::System); let topics = topics_of(&test_env.env, "category_registered") .expect("category_registered event must be emitted"); @@ -312,7 +317,7 @@ fn test_category_registered_event_carries_category_and_priority() { .expect("topic[2] must be a NotificationCategory"); assert_eq!( category, - NotificationCategory::Group, + NotificationCategory::System, "registered category must match" ); @@ -332,7 +337,7 @@ fn test_revoke_notification_event_has_notification_category() { let creator = test_env.users.get(0).unwrap().clone(); let id = make_id(&test_env.env, 30); - client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); client.revoke_notification(&id, &creator); assert_eq!( @@ -354,7 +359,7 @@ fn test_audit_record_appended_event_has_notification_category() { let relay = test_env.users.get(1).unwrap().clone(); let id = make_id(&test_env.env, 40); - client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); // The AuditRecordAppended event is emitted alongside NotificationScheduled. // After a delivery attempt it should again carry the Notification category. @@ -383,7 +388,7 @@ fn test_audit_record_appended_carries_correct_action_topic() { let relay = test_env.users.get(1).unwrap().clone(); let id = make_id(&test_env.env, 41); - client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); client.record_delivery_failure(&id, &relay); let topics = topics_of(&test_env.env, "audit_record_appended").unwrap(); @@ -440,7 +445,7 @@ fn test_notification_expired_event_has_notification_category() { set_ts(&test_env.env, 2_000); let id = make_id(&test_env.env, 50); - client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); set_ts(&test_env.env, 2_000 + ONE_HOUR); client.expire_notification(&id); @@ -460,7 +465,7 @@ fn test_notification_delivered_event_has_notification_category() { let creator = test_env.users.get(0).unwrap().clone(); let id = make_id(&test_env.env, 51); - client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); client.confirm_notification_delivery(&id, &creator); assert_eq!( @@ -477,7 +482,7 @@ fn test_notification_recalled_event_has_notification_category() { let creator = test_env.users.get(0).unwrap().clone(); let id = make_id(&test_env.env, 52); - client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); client.recall_notification(&id, &creator); assert_eq!( @@ -506,7 +511,7 @@ fn test_audit_log_grows_across_independent_notifications() { ]; for id in &ids { - client.schedule_notification(id, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(id, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); } // 3 Created records. assert_eq!(client.get_audit_log().len(), 3); @@ -535,7 +540,7 @@ fn test_audit_log_seq_starts_at_one() { let creator = test_env.users.get(0).unwrap().clone(); let id = make_id(&test_env.env, 70); - client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); let log = client.get_audit_log(); assert_eq!(log.len(), 1); @@ -552,8 +557,8 @@ fn test_audit_records_per_notification_match_full_log_subset() { let id_a = make_id(&test_env.env, 80); let id_b = make_id(&test_env.env, 81); - client.schedule_notification(&id_a, &creator, &ONE_HOUR, &title(&test_env.env)); - client.schedule_notification(&id_b, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(&id_a, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); + client.schedule_notification(&id_b, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); client.record_delivery_attempt(&id_a, &relay); client.record_delivery_failure(&id_a, &relay); client.record_delivery_attempt(&id_b, &relay); @@ -629,7 +634,7 @@ fn test_notification_accessed_has_notification_category() { let accessor = test_env.users.get(1).unwrap().clone(); let id = make_id(&test_env.env, 90); - client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, &title(&test_env.env), &NotificationPriority::Medium); client.record_notification_access(&id, &accessor); let topics = topics_of(&test_env.env, "notification_accessed") diff --git a/contract/contracts/hello-world/src/tests/fuzz_test.rs b/contract/contracts/hello-world/src/tests/fuzz_test.rs index 3c30dc20..bfa12280 100644 --- a/contract/contracts/hello-world/src/tests/fuzz_test.rs +++ b/contract/contracts/hello-world/src/tests/fuzz_test.rs @@ -176,11 +176,11 @@ fn fuzz_reduce_usage_never_exceeds_paid_total() { ); for _ in 0..usages { - client.reduce_usage(&id); + client.reduce_usage(&id, &creator); } assert_eq!(client.get_remaining_usages(&id), 0); - let overuse = client.try_reduce_usage(&id); + let overuse = client.try_reduce_usage(&id, &creator); assert!(overuse.is_err()); } diff --git a/contract/contracts/hello-world/src/tests/metadata_validation_test.rs b/contract/contracts/hello-world/src/tests/metadata_validation_test.rs index bf07f605..f10783c9 100644 --- a/contract/contracts/hello-world/src/tests/metadata_validation_test.rs +++ b/contract/contracts/hello-world/src/tests/metadata_validation_test.rs @@ -3,6 +3,7 @@ //! Complements the unit tests in `metadata_validation.rs` by exercising //! validation through the public `schedule_notification` entrypoint. +use crate::base::events::NotificationPriority; use crate::test_utils::setup_test_env; use crate::AutoShareContractClient; @@ -31,6 +32,7 @@ fn test_schedule_rejects_empty_title() { &creator, &ONE_HOUR, &String::from_str(&test_env.env, ""), + &NotificationPriority::Medium, ); } @@ -46,6 +48,7 @@ fn test_schedule_accepts_valid_title() { &creator, &ONE_HOUR, &String::from_str(&test_env.env, "Valid title"), + &NotificationPriority::Medium, ); let stored = client.get_notification(&id); @@ -62,10 +65,12 @@ fn test_batch_rejects_empty_title() { let mut ids = soroban_sdk::Vec::new(&test_env.env); let mut ttls = soroban_sdk::Vec::new(&test_env.env); let mut titles = soroban_sdk::Vec::new(&test_env.env); + let mut priorities = soroban_sdk::Vec::new(&test_env.env); ids.push_back(make_id(&test_env.env, 3)); ttls.push_back(ONE_HOUR); titles.push_back(String::from_str(&test_env.env, "")); + priorities.push_back(NotificationPriority::Medium); - client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles, &priorities); } diff --git a/contract/contracts/hello-world/src/tests/notification_lifetime_test.rs b/contract/contracts/hello-world/src/tests/notification_lifetime_test.rs index 7298e159..7fbd2264 100644 --- a/contract/contracts/hello-world/src/tests/notification_lifetime_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_lifetime_test.rs @@ -14,6 +14,7 @@ //! - Extension that keeps total lifetime exactly at max → succeeds. //! - Batch with one entry over max → entire batch rejected. +use crate::base::events::NotificationPriority; use crate::test_utils::setup_test_env; use crate::AutoShareContractClient; @@ -31,6 +32,15 @@ fn notification_title(env: &Env) -> String { String::from_str(env, "Lifetime test notification") } +/// Builds a `priorities` vector of length `n` for `batch_schedule_notifications`. +fn batch_priorities(env: &Env, n: u32) -> soroban_sdk::Vec { + let mut priorities = soroban_sdk::Vec::new(env); + for _ in 0..n { + priorities.push_back(NotificationPriority::Medium); + } + priorities +} + fn make_id(env: &Env, tag: u8) -> BytesN<32> { let mut bytes = [0u8; 32]; bytes[0] = tag; @@ -54,8 +64,7 @@ fn test_schedule_at_max_lifetime_succeeds() { &id, &creator, &MAX_LIFETIME, - ¬ification_title(&test_env.env), - ); + ¬ification_title(&test_env.env), &NotificationPriority::Medium); assert!( result.is_ok(), "scheduling at exactly the max lifetime must succeed" @@ -77,8 +86,7 @@ fn test_schedule_one_second_over_max_lifetime_rejected() { &id, &creator, &(MAX_LIFETIME + 1), - ¬ification_title(&test_env.env), - ); + ¬ification_title(&test_env.env), &NotificationPriority::Medium); assert!( result.is_err(), "a ttl_seconds value one second over the max must be rejected" @@ -94,7 +102,7 @@ fn test_schedule_minimum_valid_lifetime_succeeds() { let id = make_id(&test_env.env, 3); let result = - client.try_schedule_notification(&id, &creator, &1u64, ¬ification_title(&test_env.env)); + client.try_schedule_notification(&id, &creator, &1u64, ¬ification_title(&test_env.env), &NotificationPriority::Medium); assert!( result.is_ok(), "scheduling with the minimum valid ttl (1 second) must succeed" @@ -110,7 +118,7 @@ fn test_schedule_zero_lifetime_rejected() { let id = make_id(&test_env.env, 4); let result = - client.try_schedule_notification(&id, &creator, &0u64, ¬ification_title(&test_env.env)); + client.try_schedule_notification(&id, &creator, &0u64, ¬ification_title(&test_env.env), &NotificationPriority::Medium); assert!(result.is_err(), "zero ttl_seconds must be rejected"); } @@ -126,8 +134,7 @@ fn test_schedule_absurd_lifetime_rejected() { &id, &creator, &u64::MAX, - ¬ification_title(&test_env.env), - ); + ¬ification_title(&test_env.env), &NotificationPriority::Medium); assert!(result.is_err(), "u64::MAX ttl_seconds must be rejected"); } @@ -143,8 +150,7 @@ fn test_schedule_typical_one_hour_lifetime_succeeds() { &id, &creator, &ONE_HOUR, - ¬ification_title(&test_env.env), - ); + ¬ification_title(&test_env.env), &NotificationPriority::Medium); assert!( result.is_ok(), "scheduling with a typical 1-hour TTL must succeed" @@ -165,7 +171,7 @@ fn test_extend_to_exactly_max_lifetime_succeeds() { // Schedule at created_at=1000 with ONE_HOUR TTL. test_env.env.ledger().set_timestamp(1_000); let id = make_id(&test_env.env, 10); - client.schedule_notification(&id, &creator, &ONE_HOUR, ¬ification_title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, ¬ification_title(&test_env.env), &NotificationPriority::Medium); // Extend so that total lifetime = MAX_LIFETIME exactly. let extension = MAX_LIFETIME - ONE_HOUR; @@ -188,7 +194,7 @@ fn test_extend_one_second_over_max_lifetime_rejected() { test_env.env.ledger().set_timestamp(1_000); let id = make_id(&test_env.env, 11); - client.schedule_notification(&id, &creator, &ONE_HOUR, ¬ification_title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, ¬ification_title(&test_env.env), &NotificationPriority::Medium); // Extension that would make total lifetime = MAX_LIFETIME + 1. let extension = MAX_LIFETIME - ONE_HOUR + 1; @@ -208,7 +214,7 @@ fn test_extend_by_zero_seconds_rejected() { test_env.env.ledger().set_timestamp(1_000); let id = make_id(&test_env.env, 12); - client.schedule_notification(&id, &creator, &ONE_HOUR, ¬ification_title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, ¬ification_title(&test_env.env), &NotificationPriority::Medium); let result = client.try_extend_notification_expiry(&id, &creator, &0u64); assert!( @@ -226,7 +232,7 @@ fn test_extend_by_absurd_seconds_rejected() { test_env.env.ledger().set_timestamp(1_000); let id = make_id(&test_env.env, 13); - client.schedule_notification(&id, &creator, &ONE_HOUR, ¬ification_title(&test_env.env)); + client.schedule_notification(&id, &creator, &ONE_HOUR, ¬ification_title(&test_env.env), &NotificationPriority::Medium); let result = client.try_extend_notification_expiry(&id, &creator, &u64::MAX); assert!(result.is_err(), "extending by u64::MAX must be rejected"); @@ -251,7 +257,9 @@ fn test_batch_schedule_at_max_lifetime_succeeds() { ttls.push_back(MAX_LIFETIME); titles.push_back(notification_title(&test_env.env)); - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); + let priorities = batch_priorities(&test_env.env, ttls.len()); + let result = + client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles, &priorities); assert!( result.is_ok(), "a batch entry at exactly the max TTL must succeed" @@ -279,7 +287,9 @@ fn test_batch_schedule_one_second_over_max_rejected() { ttls.push_back(MAX_LIFETIME + 1); titles.push_back(notification_title(&test_env.env)); - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); + let priorities = batch_priorities(&test_env.env, ttls.len()); + let result = + client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles, &priorities); assert!( result.is_err(), "a batch containing an over-max TTL must reject the entire batch" @@ -309,6 +319,8 @@ fn test_batch_schedule_zero_lifetime_rejected() { ttls.push_back(0u64); titles.push_back(notification_title(&test_env.env)); - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); + let priorities = batch_priorities(&test_env.env, ttls.len()); + let result = + client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles, &priorities); assert!(result.is_err(), "a batch with a zero TTL must be rejected"); } 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 1f5c1857..9753fdd8 100644 --- a/contract/contracts/hello-world/src/tests/notification_validation_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_validation_test.rs @@ -21,8 +21,13 @@ use soroban_sdk::{Address, BytesN, String, TryFromVal, Vec}; 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() + // Every event lays its trailing topics out as `[.., category, priority]`, so + // the category is the second-to-last topic (the last one is the priority). + let n = topics.len(); + if n < 2 { + return None; + } + NotificationCategory::try_from_val(env, &topics.get(n - 2)?).ok() } // ── create: invalid payload — zero usage count ─────────────────────────────── @@ -803,8 +808,8 @@ fn test_reduce_usage_below_zero_is_rejected() { &token, ); - client.reduce_usage(&id); // consumes the last usage - client.reduce_usage(&id); // must panic: NoUsagesRemaining + client.reduce_usage(&id, &creator); // consumes the last usage + client.reduce_usage(&id, &creator); // must panic: NoUsagesRemaining } /// Reducing usage on a non-existent group must be rejected. @@ -815,7 +820,7 @@ fn test_reduce_usage_nonexistent_group_is_rejected() { let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); let ghost_id = BytesN::from_array(&test_env.env, &[0xBBu8; 32]); - client.reduce_usage(&ghost_id); + client.reduce_usage(&ghost_id, &test_env.users.get(0).unwrap()); } // ── set_usage_fee: invalid payloads ────────────────────────────────────────── diff --git a/contract/contracts/hello-world/src/tests/notification_version_test.rs b/contract/contracts/hello-world/src/tests/notification_version_test.rs index 6f5a3dbd..96d636c5 100644 --- a/contract/contracts/hello-world/src/tests/notification_version_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_version_test.rs @@ -4,6 +4,7 @@ //! consumers can gate parsing logic. The current version is documented as //! [`CURRENT_NOTIFICATION_VERSION`] (currently `1`). +use crate::base::events::NotificationPriority; use crate::base::types::CURRENT_NOTIFICATION_VERSION; use crate::test_utils::setup_test_env; use crate::AutoShareContractClient; @@ -41,8 +42,7 @@ fn test_scheduled_notification_includes_version() { &id, &creator, &ONE_HOUR, - &String::from_str(&test_env.env, "Versioned notice"), - ); + &String::from_str(&test_env.env, "Versioned notice"), &NotificationPriority::Medium); let stored = client.get_notification(&id); assert_eq!(stored.version, CURRENT_NOTIFICATION_VERSION); @@ -58,14 +58,16 @@ fn test_batch_scheduled_notifications_include_version() { let mut ids = soroban_sdk::Vec::new(&test_env.env); let mut ttls = soroban_sdk::Vec::new(&test_env.env); let mut titles = soroban_sdk::Vec::new(&test_env.env); + let mut priorities = soroban_sdk::Vec::new(&test_env.env); for i in 0u8..3 { ids.push_back(make_id(&test_env.env, 20 + i)); ttls.push_back(ONE_HOUR); titles.push_back(String::from_str(&test_env.env, "batch item")); + priorities.push_back(NotificationPriority::Medium); } - client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles, &priorities); for i in 0..3 { let stored = client.get_notification(&ids.get(i).unwrap()); diff --git a/contract/contracts/hello-world/src/tests/payload_validation_test.rs b/contract/contracts/hello-world/src/tests/payload_validation_test.rs index fca2d937..5668f221 100644 --- a/contract/contracts/hello-world/src/tests/payload_validation_test.rs +++ b/contract/contracts/hello-world/src/tests/payload_validation_test.rs @@ -616,6 +616,7 @@ fn test_consumer_can_filter_by_category() { Some(NotificationCategory::Admin) => admin_events += 1, Some(NotificationCategory::Notification) => notification_events += 1, Some(NotificationCategory::Financial) => financial_events += 1, + Some(NotificationCategory::System) => {} None => {} }; diff --git a/contract/contracts/hello-world/src/tests/template_registry_test.rs b/contract/contracts/hello-world/src/tests/template_registry_test.rs index 49fe7680..2f41b17c 100644 --- a/contract/contracts/hello-world/src/tests/template_registry_test.rs +++ b/contract/contracts/hello-world/src/tests/template_registry_test.rs @@ -196,10 +196,10 @@ mod template_registry_tests { let owner = test_env.users.get(0).unwrap(); let id = make_id(&test_env.env, 6); - // 101-character name — exceeds the 100-byte limit. + // 110-character name — exceeds the 100-byte limit. let long_name = String::from_str( &test_env.env, - "aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeaaaaaaaaaabbbbbbbbbbccccccccccdddddddddde1", + "aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffffgggggggggghhhhhhhhhhiiiiiiiiiijjjjjjjjjjkkkkkkkkkk", ); let content = String::from_str(&test_env.env, "Some content."); From 53fbd72a1443a32ae70deeecb66387125c04d444 Mon Sep 17 00:00:00 2001 From: Collins C Augustine Date: Sun, 30 Aug 2026 21:08:59 +0100 Subject: [PATCH 3/3] test(contract): restore reputation_test suite, fix a skipped test, clear warnings - add tests/reputation_test.rs (12 contract-level reputation tests) that previously lived only on the stale remote branch; ported to the current client API (record_delivery_success) - revocation_test: add the missing #[test] on test_revoke_notification_while_contract_paused_fails so it actually runs - clear compiler warnings across the re-enabled suites: drop unused imports, remove dead helper fns (topics_of / data_of), replace deprecated String::from_slice with String::from_str in metadata_validation, elide lifetimes in access_log_test / schema_version_test setup helpers - scope the test-only Env import inside base::reputation::tests Full suite: 458 passing, 0 failing, 0 warnings. Co-Authored-By: Claude Sonnet 5 --- .../src/base/metadata_validation.rs | 40 +-- .../hello-world/src/base/reputation.rs | 3 +- .../hello-world/src/channel_logic.rs | 2 +- contract/contracts/hello-world/src/lib.rs | 1 + .../hello-world/src/tests/access_log_test.rs | 2 +- .../src/tests/archive_notification_test.rs | 21 +- .../hello-world/src/tests/audit_log_test.rs | 2 +- .../hello-world/src/tests/batch_ack_test.rs | 4 +- .../hello-world/src/tests/batch_event_test.rs | 2 +- .../src/tests/batch_notification_test.rs | 2 +- .../hello-world/src/tests/fuzz_test.rs | 2 - .../src/tests/notification_lifetime_test.rs | 2 +- .../src/tests/notification_test.rs | 1 - .../src/tests/payload_validation_test.rs | 1 - .../hello-world/src/tests/reputation_test.rs | 293 ++++++++++++++++++ .../hello-world/src/tests/revocation_test.rs | 19 +- .../src/tests/schema_version_test.rs | 2 +- .../src/tests/storage_optimization_test.rs | 4 +- .../tests/subscription_cancellation_test.rs | 3 +- .../src/tests/template_registry_test.rs | 1 - 20 files changed, 331 insertions(+), 76 deletions(-) create mode 100644 contract/contracts/hello-world/src/tests/reputation_test.rs diff --git a/contract/contracts/hello-world/src/base/metadata_validation.rs b/contract/contracts/hello-world/src/base/metadata_validation.rs index 2a39a984..ed9e596b 100644 --- a/contract/contracts/hello-world/src/base/metadata_validation.rs +++ b/contract/contracts/hello-world/src/base/metadata_validation.rs @@ -140,7 +140,7 @@ mod tests { #[test] fn test_valid_metadata() { let metadata = NotificationMetadata { - title: String::from_slice(&soroban_sdk::Env::default(), "Test"), + title: String::from_str(&soroban_sdk::Env::default(), "Test"), description: None, data_uri: None, custom_fields: None, @@ -151,7 +151,7 @@ mod tests { #[test] fn test_empty_title_invalid() { let metadata = NotificationMetadata { - title: String::from_slice(&soroban_sdk::Env::default(), ""), + title: String::from_str(&soroban_sdk::Env::default(), ""), description: None, data_uri: None, custom_fields: None, @@ -163,7 +163,7 @@ mod tests { fn test_long_title_invalid() { let env = soroban_sdk::Env::default(); let long_string = - String::from_slice(&env, &"a".repeat(MAX_METADATA_STRING_LENGTH as usize + 1)); + String::from_str(&env, &"a".repeat(MAX_METADATA_STRING_LENGTH as usize + 1)); let metadata = NotificationMetadata { title: long_string, description: None, @@ -177,9 +177,9 @@ mod tests { fn test_long_description_invalid() { let env = soroban_sdk::Env::default(); let long_desc = - String::from_slice(&env, &"d".repeat(MAX_METADATA_STRING_LENGTH as usize + 1)); + String::from_str(&env, &"d".repeat(MAX_METADATA_STRING_LENGTH as usize + 1)); let metadata = NotificationMetadata { - title: String::from_slice(&env, "ok"), + title: String::from_str(&env, "ok"), description: Some(long_desc), data_uri: None, custom_fields: None, @@ -191,9 +191,9 @@ mod tests { fn test_long_data_uri_invalid() { let env = soroban_sdk::Env::default(); let long_uri = - String::from_slice(&env, &"u".repeat(MAX_METADATA_STRING_LENGTH as usize + 1)); + String::from_str(&env, &"u".repeat(MAX_METADATA_STRING_LENGTH as usize + 1)); let metadata = NotificationMetadata { - title: String::from_slice(&env, "ok"), + title: String::from_str(&env, "ok"), description: None, data_uri: Some(long_uri), custom_fields: None, @@ -208,13 +208,13 @@ mod tests { let mut i = 0u32; while i < MAX_METADATA_FIELDS + 1 { let key_bytes = [b'k', b'0' + ((i / 10) as u8), b'0' + ((i % 10) as u8)]; - let key = String::from_slice(&env, core::str::from_utf8(&key_bytes).unwrap()); - let val = String::from_slice(&env, "v"); + let key = String::from_str(&env, core::str::from_utf8(&key_bytes).unwrap()); + let val = String::from_str(&env, "v"); fields.set(key, val); i += 1; } let metadata = NotificationMetadata { - title: String::from_slice(&env, "ok"), + title: String::from_str(&env, "ok"), description: None, data_uri: None, custom_fields: Some(fields), @@ -227,13 +227,13 @@ mod tests { let env = soroban_sdk::Env::default(); let mut fields = Map::new(&env); fields.set( - String::from_slice(&env, "priority"), - String::from_slice(&env, "high"), + String::from_str(&env, "priority"), + String::from_str(&env, "high"), ); let metadata = NotificationMetadata { - title: String::from_slice(&env, "Alert"), - description: Some(String::from_slice(&env, "Body")), - data_uri: Some(String::from_slice(&env, "ipfs://abc")), + title: String::from_str(&env, "Alert"), + description: Some(String::from_str(&env, "Body")), + data_uri: Some(String::from_str(&env, "ipfs://abc")), custom_fields: Some(fields), }; assert!(validate_metadata(&metadata).is_ok()); @@ -247,15 +247,15 @@ mod tests { let mut i = 0u32; while i < MAX_METADATA_FIELDS { let key_bytes = [b'k', b'0' + ((i / 10) as u8), b'0' + ((i % 10) as u8)]; - let key = String::from_slice(&env, core::str::from_utf8(&key_bytes).unwrap()); - let val = String::from_slice(&env, &"x".repeat(256)); + let key = String::from_str(&env, core::str::from_utf8(&key_bytes).unwrap()); + let val = String::from_str(&env, &"x".repeat(256)); fields.set(key, val); i += 1; } let metadata = NotificationMetadata { - title: String::from_slice(&env, &"t".repeat(256)), - description: Some(String::from_slice(&env, &"d".repeat(256))), - data_uri: Some(String::from_slice(&env, &"u".repeat(256))), + title: String::from_str(&env, &"t".repeat(256)), + description: Some(String::from_str(&env, &"d".repeat(256))), + data_uri: Some(String::from_str(&env, &"u".repeat(256))), custom_fields: Some(fields), }; assert!(validate_metadata_size(&metadata).is_err()); diff --git a/contract/contracts/hello-world/src/base/reputation.rs b/contract/contracts/hello-world/src/base/reputation.rs index 2d5758fe..d13d207d 100644 --- a/contract/contracts/hello-world/src/base/reputation.rs +++ b/contract/contracts/hello-world/src/base/reputation.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contracttype, Address}; /// Sender reputation score and metrics. /// @@ -130,6 +130,7 @@ impl SenderReputation { #[cfg(test)] mod tests { use super::*; + use soroban_sdk::Env; #[test] fn test_reputation_tier_classification() { diff --git a/contract/contracts/hello-world/src/channel_logic.rs b/contract/contracts/hello-world/src/channel_logic.rs index 551f024f..b230d105 100644 --- a/contract/contracts/hello-world/src/channel_logic.rs +++ b/contract/contracts/hello-world/src/channel_logic.rs @@ -105,7 +105,7 @@ pub fn unsubscribe(env: Env, channel_id: BytesN<32>, subscriber: Address) -> Res set_subscribed(&env, &channel_id, &subscriber, false); - let mut subscribers = load_subscribers(&env, &channel_id); + let subscribers = load_subscribers(&env, &channel_id); let mut next = Vec::new(&env); for addr in subscribers.iter() { if addr != subscriber { diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index 90999f72..a0174e63 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -893,6 +893,7 @@ mod tests { mod pause_test; mod payload_validation_test; mod preferences_test; + mod reputation_test; mod revocation_test; mod schema_version_test; mod storage_optimization_test; diff --git a/contract/contracts/hello-world/src/tests/access_log_test.rs b/contract/contracts/hello-world/src/tests/access_log_test.rs index 244d7458..9ba59b7e 100644 --- a/contract/contracts/hello-world/src/tests/access_log_test.rs +++ b/contract/contracts/hello-world/src/tests/access_log_test.rs @@ -2,7 +2,7 @@ use crate::base::events::NotificationPriority; use crate::{AutoShareContract, AutoShareContractClient}; use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String}; -fn setup(env: &Env) -> (Address, AutoShareContractClient) { +fn setup(env: &Env) -> (Address, AutoShareContractClient<'_>) { let id = env.register(AutoShareContract, ()); let client = AutoShareContractClient::new(env, &id); let admin = Address::generate(env); diff --git a/contract/contracts/hello-world/src/tests/archive_notification_test.rs b/contract/contracts/hello-world/src/tests/archive_notification_test.rs index 8342133f..4f4a8994 100644 --- a/contract/contracts/hello-world/src/tests/archive_notification_test.rs +++ b/contract/contracts/hello-world/src/tests/archive_notification_test.rs @@ -10,8 +10,8 @@ use crate::AutoShareContractClient; extern crate std; -use soroban_sdk::testutils::{Events, Ledger}; -use soroban_sdk::{BytesN, Env, String, Symbol, TryFromVal, Val, Vec}; +use soroban_sdk::testutils::Ledger; +use soroban_sdk::{BytesN, Env, String}; const ONE_HOUR: u64 = 3_600; @@ -25,23 +25,6 @@ fn set_now(env: &Env, timestamp: u64) { env.ledger().set_timestamp(timestamp); } -fn topics_of(env: &Env, event_name: &str) -> Option> { - let target = Symbol::new(env, event_name); - let mut found: Option> = None; - for (_addr, topics, _data) in env.events().all().iter() { - if topics.is_empty() { - continue; - } - let first = topics.get(0).unwrap(); - if let Ok(name) = Symbol::try_from_val(env, &first) { - if name == target { - found = Some(topics); - } - } - } - found -} - #[test] fn test_expire_archives_notification() { let test_env = setup_test_env(); diff --git a/contract/contracts/hello-world/src/tests/audit_log_test.rs b/contract/contracts/hello-world/src/tests/audit_log_test.rs index f8f6cbdf..6185d6c0 100644 --- a/contract/contracts/hello-world/src/tests/audit_log_test.rs +++ b/contract/contracts/hello-world/src/tests/audit_log_test.rs @@ -12,7 +12,7 @@ use crate::base::events::{AuditAction, NotificationCategory, NotificationPriorit use crate::test_utils::setup_test_env; use crate::AutoShareContractClient; -use soroban_sdk::testutils::{Address as _, Events, Ledger}; +use soroban_sdk::testutils::{Events, Ledger}; use soroban_sdk::{BytesN, Env, String, Symbol, TryFromVal, Val, Vec}; const ONE_HOUR: u64 = 3_600; diff --git a/contract/contracts/hello-world/src/tests/batch_ack_test.rs b/contract/contracts/hello-world/src/tests/batch_ack_test.rs index 8b8e9d4f..4a9423d7 100644 --- a/contract/contracts/hello-world/src/tests/batch_ack_test.rs +++ b/contract/contracts/hello-world/src/tests/batch_ack_test.rs @@ -6,12 +6,12 @@ //! - Correct `NotificationAcknowledged` events are emitted. //! - Gas benchmarking to prove batching is more efficient than individual calls. -use crate::base::events::{NotificationCategory, NotificationPriority}; +use crate::base::events::NotificationPriority; use crate::test_utils::setup_test_env; use crate::AutoShareContractClient; use soroban_sdk::testutils::{Address as _, Events, Ledger}; -use soroban_sdk::{Address, BytesN, Env, Symbol, TryFromVal, Val, Vec}; +use soroban_sdk::{Address, BytesN, Env, Symbol, TryFromVal, Vec}; const ONE_HOUR: u64 = 3_600; diff --git a/contract/contracts/hello-world/src/tests/batch_event_test.rs b/contract/contracts/hello-world/src/tests/batch_event_test.rs index 1312d821..7e26de47 100644 --- a/contract/contracts/hello-world/src/tests/batch_event_test.rs +++ b/contract/contracts/hello-world/src/tests/batch_event_test.rs @@ -3,7 +3,7 @@ use crate::AutoShareContractClient; use crate::base::events::NotificationCategory; use crate::base::events::NotificationPriority; use soroban_sdk::testutils::Events; -use soroban_sdk::{BytesN, Symbol, TryFromVal, Val}; +use soroban_sdk::{BytesN, Symbol, TryFromVal}; #[test] fn test_emit_batch_processing_completed_event() { diff --git a/contract/contracts/hello-world/src/tests/batch_notification_test.rs b/contract/contracts/hello-world/src/tests/batch_notification_test.rs index 19c285cb..4e68948a 100644 --- a/contract/contracts/hello-world/src/tests/batch_notification_test.rs +++ b/contract/contracts/hello-world/src/tests/batch_notification_test.rs @@ -12,7 +12,7 @@ use crate::base::events::{NotificationCategory, NotificationPriority}; use crate::test_utils::setup_test_env; use crate::AutoShareContractClient; -use soroban_sdk::testutils::{Address as _, Events, Ledger}; +use soroban_sdk::testutils::{Events, Ledger}; use soroban_sdk::{BytesN, Env, String, Symbol, TryFromVal, Val, Vec}; const ONE_HOUR: u64 = 3_600; diff --git a/contract/contracts/hello-world/src/tests/fuzz_test.rs b/contract/contracts/hello-world/src/tests/fuzz_test.rs index bfa12280..524fc1c6 100644 --- a/contract/contracts/hello-world/src/tests/fuzz_test.rs +++ b/contract/contracts/hello-world/src/tests/fuzz_test.rs @@ -11,8 +11,6 @@ use proptest::prelude::*; use soroban_sdk::testutils::Address as _; use soroban_sdk::{Address, BytesN, Env, String, Vec}; -const ONE_HOUR: u64 = 3_600; - fn notification_title(env: &Env) -> String { String::from_str(env, "Test notification") } diff --git a/contract/contracts/hello-world/src/tests/notification_lifetime_test.rs b/contract/contracts/hello-world/src/tests/notification_lifetime_test.rs index 7fbd2264..ba612be8 100644 --- a/contract/contracts/hello-world/src/tests/notification_lifetime_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_lifetime_test.rs @@ -18,7 +18,7 @@ use crate::base::events::NotificationPriority; use crate::test_utils::setup_test_env; use crate::AutoShareContractClient; -use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::testutils::Ledger; use soroban_sdk::{BytesN, Env, String}; /// 30 days in seconds — must stay in sync with `MAX_NOTIFICATION_LIFETIME_SECONDS` diff --git a/contract/contracts/hello-world/src/tests/notification_test.rs b/contract/contracts/hello-world/src/tests/notification_test.rs index 39647ae5..4781fa3e 100644 --- a/contract/contracts/hello-world/src/tests/notification_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_test.rs @@ -12,7 +12,6 @@ //! - the change is backward compatible: the event name remains the first topic //! and the previously defined topics/data are unchanged. -use crate::base::errors::Error; use crate::base::events::{NotificationCategory, NotificationPriority}; use crate::test_utils::{create_test_group, setup_test_env}; use crate::AutoShareContractClient; diff --git a/contract/contracts/hello-world/src/tests/payload_validation_test.rs b/contract/contracts/hello-world/src/tests/payload_validation_test.rs index 5668f221..43cd9f0c 100644 --- a/contract/contracts/hello-world/src/tests/payload_validation_test.rs +++ b/contract/contracts/hello-world/src/tests/payload_validation_test.rs @@ -597,7 +597,6 @@ fn test_consumer_can_filter_by_category() { // Helper: get the category of the most recently emitted event (any event). let latest_category = |env: &Env| -> Option { - use soroban_sdk::Val; let (_addr, topics, _data) = env.events().all().last()?; let n = topics.len(); if n < 2 { diff --git a/contract/contracts/hello-world/src/tests/reputation_test.rs b/contract/contracts/hello-world/src/tests/reputation_test.rs new file mode 100644 index 00000000..fb0989eb --- /dev/null +++ b/contract/contracts/hello-world/src/tests/reputation_test.rs @@ -0,0 +1,293 @@ +//! Tests for sender reputation tracking (`reputation_logic` / `base::reputation`). +//! +//! These cover the full contract-level integration surface, which previously +//! had no test coverage at all: +//! - A never-seen sender reads back sane defaults instead of erroring. +//! - Successful/failed deliveries update the stored score and tier correctly, +//! including the boundary cases (score clamped to 0 and to 100). +//! - `ReputationUpdated` fires on every recorded delivery; `ReputationTierChanged` +//! fires only when the tier actually crosses a boundary. +//! - Reputation is tracked independently per sender. +//! - Counts saturate rather than overflow/panic under heavy volume. + +use crate::base::events::NotificationCategory; +use crate::base::reputation::ReputationTier; +use crate::test_utils::setup_test_env; +use crate::AutoShareContractClient; + +use soroban_sdk::testutils::{Address as _, Events}; +use soroban_sdk::{Address, Map, Symbol, TryFromVal, Val}; + +/// Find the topics + data of the most recent event named `event_name`, if any +/// was emitted by the most recent contract invocation. +fn find_event( + env: &soroban_sdk::Env, + event_name: &str, +) -> Option<(soroban_sdk::Vec, Val)> { + let target = Symbol::new(env, event_name); + for (_addr, topics, data) in env.events().all().iter() { + if topics.is_empty() { + continue; + } + if let Ok(name) = Symbol::try_from_val(env, &topics.get(0).unwrap()) { + if name == target { + return Some((topics, data)); + } + } + } + None +} + +/// Decode a `ReputationUpdated`/`NotificationLimitsConfigured`-style map-format +/// event's data payload field by field (map-format events sort keys +/// alphabetically; `Map::get` looks up by key regardless of order). +fn map_get_i64(env: &soroban_sdk::Env, data: &Val, key: &str) -> i64 { + let map = Map::::try_from_val(env, data).unwrap(); + let val = map.get(Symbol::new(env, key)).unwrap(); + i64::try_from_val(env, &val).unwrap() +} + +fn map_get_u32(env: &soroban_sdk::Env, data: &Val, key: &str) -> u32 { + let map = Map::::try_from_val(env, data).unwrap(); + let val = map.get(Symbol::new(env, key)).unwrap(); + u32::try_from_val(env, &val).unwrap() +} + +// ── defaults for an unseen sender ──────────────────────────────────────────── + +#[test] +fn test_new_sender_has_default_reputation() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let sender = Address::generate(&test_env.env); + + let rep = client.get_sender_reputation(&sender); + assert_eq!(rep.sender, sender); + assert_eq!(rep.total_sent, 0); + assert_eq!(rep.successful_deliveries, 0); + assert_eq!(rep.failed_deliveries, 0); + assert_eq!(rep.reputation_score, 50); + + assert_eq!(client.get_sender_reputation_score(&sender), 50); + // Score 50 falls in the Bronze band (21-60). + assert_eq!(client.get_sender_reputation_tier(&sender), ReputationTier::Bronze as u32); +} + +// ── score updates from delivery outcomes ───────────────────────────────────── + +#[test] +fn test_successful_delivery_increments_counts_and_raises_score() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let sender = Address::generate(&test_env.env); + + client.record_delivery_success(&sender); + + let rep = client.get_sender_reputation(&sender); + assert_eq!(rep.total_sent, 1); + assert_eq!(rep.successful_deliveries, 1); + assert_eq!(rep.failed_deliveries, 0); + // 100% success rate -> score reaches the maximum. + assert_eq!(rep.reputation_score, 100); + assert_eq!(client.get_sender_reputation_tier(&sender), ReputationTier::Platinum as u32); +} + +#[test] +fn test_failed_delivery_increments_counts_and_floors_score() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let sender = Address::generate(&test_env.env); + + client.record_sender_delivery_failure(&sender); + + let rep = client.get_sender_reputation(&sender); + assert_eq!(rep.total_sent, 1); + assert_eq!(rep.successful_deliveries, 0); + assert_eq!(rep.failed_deliveries, 1); + // 0% success rate -> score floors at the minimum. + assert_eq!(rep.reputation_score, 0); + assert_eq!(client.get_sender_reputation_tier(&sender), ReputationTier::Unverified as u32); +} + +#[test] +fn test_mixed_deliveries_score_matches_quadratic_curve() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let sender = Address::generate(&test_env.env); + + // 1 success + 1 failure = 50% success rate -> score = 50^2 / 100 = 25, + // not a naive 50/50 average. The tier system deliberately punishes a + // merely average delivery record more harshly than a linear score would. + client.record_delivery_success(&sender); + client.record_sender_delivery_failure(&sender); + + let rep = client.get_sender_reputation(&sender); + assert_eq!(rep.total_sent, 2); + assert_eq!(rep.reputation_score, 25); + assert_eq!(client.get_sender_reputation_tier(&sender), ReputationTier::Bronze as u32); +} + +#[test] +fn test_get_reputation_score_matches_full_record() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let sender = Address::generate(&test_env.env); + + client.record_delivery_success(&sender); + client.record_delivery_success(&sender); + client.record_sender_delivery_failure(&sender); + + let rep = client.get_sender_reputation(&sender); + assert_eq!(client.get_sender_reputation_score(&sender), rep.reputation_score); +} + +// ── saturation / heavy volume ──────────────────────────────────────────────── + +#[test] +fn test_reputation_score_stays_clamped_under_heavy_success_volume() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let sender = Address::generate(&test_env.env); + + for _ in 0..50 { + client.record_delivery_success(&sender); + } + + let rep = client.get_sender_reputation(&sender); + assert_eq!(rep.total_sent, 50); + assert_eq!(rep.successful_deliveries, 50); + assert_eq!(rep.reputation_score, 100); + assert_eq!(client.get_sender_reputation_tier(&sender), ReputationTier::Platinum as u32); +} + +#[test] +fn test_reputation_score_stays_clamped_under_heavy_failure_volume() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let sender = Address::generate(&test_env.env); + + for _ in 0..50 { + client.record_sender_delivery_failure(&sender); + } + + let rep = client.get_sender_reputation(&sender); + assert_eq!(rep.total_sent, 50); + assert_eq!(rep.failed_deliveries, 50); + assert_eq!(rep.reputation_score, 0); + assert_eq!(client.get_sender_reputation_tier(&sender), ReputationTier::Unverified as u32); +} + +// ── multi-sender isolation ─────────────────────────────────────────────────── + +#[test] +fn test_reputation_is_tracked_independently_per_sender() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let reliable = Address::generate(&test_env.env); + let unreliable = Address::generate(&test_env.env); + + client.record_delivery_success(&reliable); + client.record_delivery_success(&reliable); + client.record_sender_delivery_failure(&unreliable); + client.record_sender_delivery_failure(&unreliable); + + let reliable_rep = client.get_sender_reputation(&reliable); + assert_eq!(reliable_rep.successful_deliveries, 2); + assert_eq!(reliable_rep.failed_deliveries, 0); + assert_eq!(reliable_rep.reputation_score, 100); + + let unreliable_rep = client.get_sender_reputation(&unreliable); + assert_eq!(unreliable_rep.successful_deliveries, 0); + assert_eq!(unreliable_rep.failed_deliveries, 2); + assert_eq!(unreliable_rep.reputation_score, 0); + + // A sender that was never touched is unaffected by either of the above. + let untouched = Address::generate(&test_env.env); + assert_eq!(client.get_sender_reputation_score(&untouched), 50); +} + +// ── events ──────────────────────────────────────────────────────────────────── + +#[test] +fn test_successful_delivery_emits_reputation_updated_event() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let sender = Address::generate(&test_env.env); + + client.record_delivery_success(&sender); + + let (topics, data) = + find_event(&test_env.env, "reputation_updated").expect("reputation_updated event"); + assert_eq!(topics.len(), 4); + assert_eq!( + Address::try_from_val(&test_env.env, &topics.get(1).unwrap()).unwrap(), + sender + ); + assert_eq!( + NotificationCategory::try_from_val(&test_env.env, &topics.get(2).unwrap()).unwrap(), + NotificationCategory::Notification + ); + + assert_eq!(map_get_i64(&test_env.env, &data, "new_score"), 100); + assert_eq!(map_get_u32(&test_env.env, &data, "successful_count"), 1); + assert_eq!(map_get_u32(&test_env.env, &data, "failed_count"), 0); +} + +#[test] +fn test_failed_delivery_emits_reputation_updated_event() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let sender = Address::generate(&test_env.env); + + client.record_sender_delivery_failure(&sender); + + let (_topics, data) = + find_event(&test_env.env, "reputation_updated").expect("reputation_updated event"); + assert_eq!(map_get_i64(&test_env.env, &data, "new_score"), 0); + assert_eq!(map_get_u32(&test_env.env, &data, "successful_count"), 0); + assert_eq!(map_get_u32(&test_env.env, &data, "failed_count"), 1); +} + +#[test] +fn test_tier_change_event_emitted_when_crossing_a_boundary() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let sender = Address::generate(&test_env.env); + + // Starts at score 50 (Bronze). A single success jumps straight to 100 + // (Platinum) — a tier boundary crossing that must emit the event. + client.record_delivery_success(&sender); + + let (topics, data) = find_event(&test_env.env, "reputation_tier_changed") + .expect("reputation_tier_changed event"); + assert_eq!( + Address::try_from_val(&test_env.env, &topics.get(1).unwrap()).unwrap(), + sender + ); + assert_eq!( + map_get_u32(&test_env.env, &data, "old_tier"), + ReputationTier::Bronze as u32 + ); + assert_eq!( + map_get_u32(&test_env.env, &data, "new_tier"), + ReputationTier::Platinum as u32 + ); +} + +#[test] +fn test_tier_change_event_not_emitted_when_tier_is_unchanged() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let sender = Address::generate(&test_env.env); + + // First failure: Bronze (50) -> Unverified (0). Tier changes, event fires. + client.record_sender_delivery_failure(&sender); + assert!(find_event(&test_env.env, "reputation_tier_changed").is_some()); + + // Second failure: still Unverified (0 stays clamped at 0). Tier is + // unchanged on *this* invocation, so no tier-change event should fire, + // even though the score-update event still does. + client.record_sender_delivery_failure(&sender); + assert!(find_event(&test_env.env, "reputation_updated").is_some()); + assert!(find_event(&test_env.env, "reputation_tier_changed").is_none()); +} diff --git a/contract/contracts/hello-world/src/tests/revocation_test.rs b/contract/contracts/hello-world/src/tests/revocation_test.rs index 1d87bc3d..0a992f24 100644 --- a/contract/contracts/hello-world/src/tests/revocation_test.rs +++ b/contract/contracts/hello-world/src/tests/revocation_test.rs @@ -52,24 +52,6 @@ fn topics_of(env: &Env, event_name: &str) -> Option> { found } -/// Returns the data payload of the latest event named `event_name`. -fn data_of(env: &Env, event_name: &str) -> Option { - let target = Symbol::new(env, event_name); - let mut found: Option = None; - for (_addr, topics, data) in env.events().all().iter() { - if topics.is_empty() { - continue; - } - let first = topics.get(0).unwrap(); - if let Ok(name) = Symbol::try_from_val(env, &first) { - if name == target { - found = Some(data); - } - } - } - found -} - #[test] fn test_revoke_notification_by_creator() { let test_env = setup_test_env(); @@ -240,6 +222,7 @@ fn test_cannot_expire_revoked_notification() { client.expire_notification(&id); } +#[test] #[should_panic] fn test_revoke_notification_while_contract_paused_fails() { let test_env = setup_test_env(); diff --git a/contract/contracts/hello-world/src/tests/schema_version_test.rs b/contract/contracts/hello-world/src/tests/schema_version_test.rs index 994d7ba2..ca19bea2 100644 --- a/contract/contracts/hello-world/src/tests/schema_version_test.rs +++ b/contract/contracts/hello-world/src/tests/schema_version_test.rs @@ -1,7 +1,7 @@ use crate::{AutoShareContract, AutoShareContractClient}; use soroban_sdk::{testutils::Address as _, Address, Env}; -fn setup(env: &Env) -> (Address, AutoShareContractClient) { +fn setup(env: &Env) -> (Address, AutoShareContractClient<'_>) { let id = env.register(AutoShareContract, ()); let client = AutoShareContractClient::new(env, &id); let admin = Address::generate(env); diff --git a/contract/contracts/hello-world/src/tests/storage_optimization_test.rs b/contract/contracts/hello-world/src/tests/storage_optimization_test.rs index 72c440c3..c5af5caf 100644 --- a/contract/contracts/hello-world/src/tests/storage_optimization_test.rs +++ b/contract/contracts/hello-world/src/tests/storage_optimization_test.rs @@ -61,8 +61,8 @@ mod storage_optimization_tests { use crate::base::types::GroupMember; use crate::test_utils::{create_test_group, setup_test_env}; - use crate::{AutoShareContract, AutoShareContractClient}; - use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String, Vec}; + use crate::AutoShareContractClient; + use soroban_sdk::{testutils::Address as _, Address, Vec}; /// Verifies that admin, pause status, usage fee, and supported tokens are /// correctly stored and retrieved after migrating to instance storage. diff --git a/contract/contracts/hello-world/src/tests/subscription_cancellation_test.rs b/contract/contracts/hello-world/src/tests/subscription_cancellation_test.rs index 858404b5..0b04ec24 100644 --- a/contract/contracts/hello-world/src/tests/subscription_cancellation_test.rs +++ b/contract/contracts/hello-world/src/tests/subscription_cancellation_test.rs @@ -7,12 +7,11 @@ //! 3. Edge cases: non-member cannot cancel, already-inactive group is rejected, //! paused contract blocks cancellation, member (non-creator) can cancel. -use crate::base::errors::Error; use crate::base::events::{NotificationCategory, NotificationPriority}; use crate::test_utils::{create_test_group, setup_test_env}; use crate::AutoShareContractClient; -use soroban_sdk::testutils::{Address as _, Events}; +use soroban_sdk::testutils::Events; use soroban_sdk::{Address, BytesN, Symbol, TryFromVal, Val, Vec}; // --------------------------------------------------------------------------- diff --git a/contract/contracts/hello-world/src/tests/template_registry_test.rs b/contract/contracts/hello-world/src/tests/template_registry_test.rs index 2f41b17c..eec8f287 100644 --- a/contract/contracts/hello-world/src/tests/template_registry_test.rs +++ b/contract/contracts/hello-world/src/tests/template_registry_test.rs @@ -9,7 +9,6 @@ #[cfg(test)] mod template_registry_tests { use crate::base::events::{NotificationCategory, NotificationPriority}; - use crate::base::errors::Error; use crate::test_utils::setup_test_env; use crate::{AutoShareContract, AutoShareContractClient}; use soroban_sdk::{