From a85ffbcbb0c4cff30d810b4dee2f80245dfa0b0d Mon Sep 17 00:00:00 2001 From: ScriptedBro Date: Tue, 23 Jun 2026 22:49:41 +0100 Subject: [PATCH 1/4] feat: event type filtering, batch notifications, audit logging - Add NotificationCategory and NotificationPriority as trailing topics on all emitted events so off-chain consumers can filter by type - Add batch_schedule_notifications: create up to 50 notifications in a single transaction with all-or-nothing validation and a summary event - Add AuditRecord type and append-only on-chain audit log tracking the full notification lifecycle (created, delivery attempt, delivery failed, acknowledged, cancelled, expired) - Add query endpoints: get_audit_log and get_notification_audit - Add explicit audit write helpers: record_delivery_attempt, record_delivery_failure, record_acknowledgment - Add BatchTooLarge error variant - Add AuditAction enum and AuditRecordAppended, BatchNotificationsCreated events - Add 179-test suite: payload_validation_test, batch_notification_test, audit_log_test (all passing) --- .../hello-world/src/autoshare_logic.rs | 285 +++++++- .../contracts/hello-world/src/base/errors.rs | 2 + .../contracts/hello-world/src/base/events.rs | 61 +- .../contracts/hello-world/src/base/types.rs | 24 +- contract/contracts/hello-world/src/lib.rs | 59 ++ .../hello-world/src/tests/audit_log_test.rs | 444 ++++++++++++ .../src/tests/batch_notification_test.rs | 405 +++++++++++ .../src/tests/payload_validation_test.rs | 641 ++++++++++++++++++ 8 files changed, 1915 insertions(+), 6 deletions(-) create mode 100644 contract/contracts/hello-world/src/tests/audit_log_test.rs create mode 100644 contract/contracts/hello-world/src/tests/batch_notification_test.rs create mode 100644 contract/contracts/hello-world/src/tests/payload_validation_test.rs diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index a4a2449..6f63f9c 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,10 +1,13 @@ use crate::base::errors::Error; use crate::base::events::{ - AdminTransferred, AuthorizationFailure, AutoshareCreated, AutoshareUpdated, ContractPaused, - ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationExpired, - NotificationPriority, NotificationScheduled, ScheduledNotificationCancelled, Withdrawal, + AdminTransferred, AuditAction, AuditRecordAppended, AuthorizationFailure, AutoshareCreated, + AutoshareUpdated, BatchNotificationsCreated, ContractPaused, ContractUnpaused, GroupActivated, + GroupDeactivated, NotificationCategory, NotificationExpired, NotificationPriority, + NotificationScheduled, ScheduledNotificationCancelled, Withdrawal, +}; +use crate::base::types::{ + AuditRecord, AutoShareDetails, GroupMember, PaymentHistory, ScheduledNotification, }; -use crate::base::types::{AutoShareDetails, GroupMember, PaymentHistory, ScheduledNotification}; use soroban_sdk::{contracttype, token, Address, BytesN, Env, String, Vec}; /// Maximum allowed length for AutoShare group names. @@ -24,6 +27,10 @@ pub enum DataKey { GroupMembers(BytesN<32>), IsPaused, ScheduledNotification(BytesN<32>), + /// Monotonically increasing counter for audit record sequence numbers. + AuditSeq, + /// All audit records stored in a single Vec for full-scan queries. + AuditLog, } pub fn create_autoshare( @@ -899,6 +906,13 @@ pub fn schedule_notification( }; env.storage().persistent().set(&key, ¬ification); + append_audit_record( + &env, + notification_id.clone(), + AuditAction::Created, + creator.clone(), + ); + NotificationScheduled { creator, category: NotificationCategory::Notification, @@ -944,6 +958,13 @@ pub fn expire_notification(env: Env, notification_id: BytesN<32>) -> Result<(), env.storage().persistent().remove(&key); + append_audit_record( + &env, + notification_id.clone(), + AuditAction::Expired, + env.current_contract_address(), + ); + NotificationExpired { notification_id, category: NotificationCategory::Notification, @@ -984,6 +1005,13 @@ pub fn cancel_notification( .remove(&DataKey::ScheduledNotification(notification_id.clone())); } + append_audit_record( + &env, + notification_id.clone(), + AuditAction::Cancelled, + caller.clone(), + ); + ScheduledNotificationCancelled { caller, category: NotificationCategory::Notification, @@ -994,3 +1022,252 @@ pub fn cancel_notification( Ok(()) } + +// ============================================================================ +// Batch Notification Creation +// ============================================================================ + +/// Maximum number of notifications that can be created in a single batch call. +const MAX_BATCH_SIZE: u32 = 50; + +/// Creates multiple scheduled notifications in a single transaction. +/// +/// Each `ids[i]` is paired with `ttl_seconds[i]`. Both slices must have the same +/// length and must not be empty. The length must not exceed [`MAX_BATCH_SIZE`]. +/// The same validation applied by [`schedule_notification`] is applied to each +/// entry; if any entry fails the entire call is rejected. +/// +/// A [`NotificationScheduled`] event is emitted for every created notification, +/// followed by a single [`BatchNotificationsCreated`] summary event carrying the +/// full list of ids and the count. +pub fn batch_schedule_notifications( + env: Env, + ids: Vec>, + creator: Address, + ttl_seconds: Vec, +) -> Result<(), Error> { + creator.require_auth(); + + if get_paused_status(&env) { + return Err(Error::ContractPaused); + } + + let count = ids.len(); + + // Must have at least one notification. + if count == 0 { + return Err(Error::InvalidInput); + } + + // Lengths must match. + if count != ttl_seconds.len() { + return Err(Error::InvalidInput); + } + + // Enforce maximum batch size. + if count > MAX_BATCH_SIZE { + return Err(Error::BatchTooLarge); + } + + let created_at = env.ledger().timestamp(); + + // Validate all entries before persisting any (all-or-nothing semantics). + // Also track ids seen within this batch to catch intra-batch duplicates. + let mut seen_in_batch: Vec> = Vec::new(&env); + for i in 0..count { + let ttl = ttl_seconds.get(i).unwrap(); + if ttl == 0 { + return Err(Error::InvalidExpirationDuration); + } + let id = ids.get(i).unwrap(); + + // Check for intra-batch duplicates. + for seen in seen_in_batch.iter() { + if seen == id { + return Err(Error::AlreadyExists); + } + } + seen_in_batch.push_back(id.clone()); + + let key = DataKey::ScheduledNotification(id.clone()); + if env.storage().persistent().has(&key) { + return Err(Error::AlreadyExists); + } + // Validate ttl doesn't overflow. + created_at + .checked_add(ttl) + .ok_or(Error::InvalidExpirationDuration)?; + } + + // Persist and emit per-notification events. + for i in 0..count { + let ttl = ttl_seconds.get(i).unwrap(); + let id = ids.get(i).unwrap(); + let expires_at = created_at + ttl; + + let notification = ScheduledNotification { + id: id.clone(), + creator: creator.clone(), + created_at, + expires_at, + }; + let key = DataKey::ScheduledNotification(id.clone()); + env.storage().persistent().set(&key, ¬ification); + + append_audit_record(&env, id.clone(), AuditAction::Created, creator.clone()); + + NotificationScheduled { + creator: creator.clone(), + category: NotificationCategory::Notification, + priority: NOTIFICATION_PRIORITY, + notification_id: id.clone(), + } + .publish(&env); + } + + // Summary event. + BatchNotificationsCreated { + creator: creator.clone(), + category: NotificationCategory::Notification, + priority: NOTIFICATION_PRIORITY, + count, + ids, + } + .publish(&env); + + Ok(()) +} + +// ============================================================================ +// Audit Logging +// ============================================================================ + +/// Appends an immutable [`AuditRecord`] to the on-chain audit log and emits an +/// [`AuditRecordAppended`] event. The sequence number is auto-incremented. +fn append_audit_record( + env: &Env, + notification_id: BytesN<32>, + action: AuditAction, + actor: Address, +) { + // Increment sequence counter. + let seq_key = DataKey::AuditSeq; + let seq: u64 = env + .storage() + .instance() + .get(&seq_key) + .unwrap_or(0u64) + + 1; + env.storage().instance().set(&seq_key, &seq); + + let timestamp = env.ledger().timestamp(); + + let record = AuditRecord { + seq, + notification_id: notification_id.clone(), + action, + actor: actor.clone(), + timestamp, + }; + + // Append to the full log (used for full-scan / range queries). + let log_key = DataKey::AuditLog; + let mut log: Vec = env + .storage() + .persistent() + .get(&log_key) + .unwrap_or(Vec::new(env)); + log.push_back(record); + env.storage().persistent().set(&log_key, &log); + + AuditRecordAppended { + notification_id, + action, + category: NotificationCategory::Notification, + seq, + actor, + timestamp, + } + .publish(env); +} + +/// Returns all audit records in creation order. +/// +/// Records are immutable and append-only; this list can only grow over time. +pub fn get_audit_log(env: Env) -> Vec { + env.storage() + .persistent() + .get(&DataKey::AuditLog) + .unwrap_or(Vec::new(&env)) +} + +/// Returns all audit records for a specific notification identifier. +pub fn get_audit_records_for_notification( + env: Env, + notification_id: BytesN<32>, +) -> Vec { + let log: Vec = env + .storage() + .persistent() + .get(&DataKey::AuditLog) + .unwrap_or(Vec::new(&env)); + + let mut result: Vec = Vec::new(&env); + for record in log.iter() { + if record.notification_id == notification_id { + result.push_back(record); + } + } + result +} + +/// Records a delivery attempt for a notification in the audit log. +/// +/// This is a permissionless write so any authorised service (an off-chain +/// relay, a keeper) can record that it attempted delivery. +pub fn record_delivery_attempt( + env: Env, + notification_id: BytesN<32>, + actor: Address, +) -> Result<(), Error> { + actor.require_auth(); + + if get_paused_status(&env) { + return Err(Error::ContractPaused); + } + + append_audit_record(&env, notification_id, AuditAction::DeliveryAttempt, actor); + Ok(()) +} + +/// Records a delivery failure for a notification in the audit log. +pub fn record_delivery_failure( + env: Env, + notification_id: BytesN<32>, + actor: Address, +) -> Result<(), Error> { + actor.require_auth(); + + if get_paused_status(&env) { + return Err(Error::ContractPaused); + } + + append_audit_record(&env, notification_id, AuditAction::DeliveryFailed, actor); + Ok(()) +} + +/// Records that the recipient acknowledged a notification. +pub fn record_acknowledgment( + env: Env, + notification_id: BytesN<32>, + actor: Address, +) -> Result<(), Error> { + actor.require_auth(); + + if get_paused_status(&env) { + return Err(Error::ContractPaused); + } + + append_audit_record(&env, notification_id, AuditAction::Acknowledged, actor); + Ok(()) +} diff --git a/contract/contracts/hello-world/src/base/errors.rs b/contract/contracts/hello-world/src/base/errors.rs index af1d286..e23d816 100644 --- a/contract/contracts/hello-world/src/base/errors.rs +++ b/contract/contracts/hello-world/src/base/errors.rs @@ -56,4 +56,6 @@ pub enum Error { /// Triggered when attempting to expire a notification whose lifetime has not /// yet elapsed. NotificationNotExpired = 25, + /// Triggered when a batch operation exceeds the maximum allowed size. + BatchTooLarge = 26, } diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index 675a937..de3980c 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contractevent, contracttype, Address, BytesN, String}; +use soroban_sdk::{contractevent, contracttype, Address, BytesN, String, Vec}; /// High-level notification category attached to every emitted event. /// @@ -217,3 +217,62 @@ pub struct NotificationExpired { pub priority: NotificationPriority, pub expires_at: u64, } + +// ============================================================================ +// Audit Logging +// ============================================================================ + +/// Discriminator for each stage in the notification lifecycle that the audit +/// log tracks. Values are fixed-width integers so they serialise compactly on +/// chain and can be matched exactly by off-chain indexers. +#[contracttype] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AuditAction { + /// A notification was created (scheduled on-chain). + Created = 0, + /// A delivery attempt was made for a notification. + DeliveryAttempt = 1, + /// A delivery attempt failed. + DeliveryFailed = 2, + /// The recipient acknowledged the notification. + Acknowledged = 3, + /// The notification was cancelled before expiry. + Cancelled = 4, + /// The notification expired naturally. + Expired = 5, +} + +/// Emitted when a new audit record is appended to the on-chain log. +/// +/// Off-chain indexers should key off `(notification_id, action)` to track the +/// full lifecycle of each notification. +#[contractevent] +#[derive(Clone)] +pub struct AuditRecordAppended { + #[topic] + pub notification_id: BytesN<32>, + #[topic] + pub action: AuditAction, + #[topic] + pub category: NotificationCategory, + pub seq: u64, + pub actor: Address, + pub timestamp: u64, +} + +/// Emitted when a batch of notifications is created in a single transaction. +/// +/// Each per-notification event is still emitted individually; this summary +/// event additionally carries the count so consumers can verify completeness. +#[contractevent] +#[derive(Clone)] +pub struct BatchNotificationsCreated { + #[topic] + pub creator: Address, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub count: u32, + pub ids: Vec>, +} diff --git a/contract/contracts/hello-world/src/base/types.rs b/contract/contracts/hello-world/src/base/types.rs index 6567595..347df10 100644 --- a/contract/contracts/hello-world/src/base/types.rs +++ b/contract/contracts/hello-world/src/base/types.rs @@ -1,4 +1,4 @@ -use crate::base::events::NotificationPriority; +use crate::base::events::{AuditAction, NotificationPriority}; use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; #[contracttype] @@ -45,3 +45,25 @@ pub struct PaymentHistory { pub amount_paid: i128, pub timestamp: u64, } + +/// Immutable record of a single notification lifecycle event. +/// +/// Records are appended to persistent storage in order of occurrence and can +/// never be modified or deleted after creation, satisfying the audit-log +/// immutability requirement. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuditRecord { + /// Sequential, 1-based index assigned at append time. Provides a stable + /// ordering handle for range queries. + pub seq: u64, + /// The notification identifier this record belongs to (all-zeros for + /// contract-level actions such as pause/unpause). + pub notification_id: BytesN<32>, + /// Which lifecycle stage this record represents. + pub action: AuditAction, + /// Who triggered the action (caller or creator). + pub actor: Address, + /// Ledger timestamp (seconds) when the action occurred. + pub timestamp: u64, +} diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index bb7aa24..23c7668 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -289,6 +289,56 @@ impl AutoShareContract { pub fn expire_notification(env: Env, notification_id: BytesN<32>) { autoshare_logic::expire_notification(env, notification_id).unwrap(); } + + // ============================================================================ + // Batch Notification Creation + // ============================================================================ + + /// Creates multiple scheduled notifications in a single transaction. + /// + /// `ids` and `ttl_seconds` must have the same length, must not be empty, and + /// must not exceed 50 entries. Emits one `NotificationScheduled` event per + /// notification plus a single `BatchNotificationsCreated` summary event. + pub fn batch_schedule_notifications( + env: Env, + ids: Vec>, + creator: Address, + ttl_seconds: Vec, + ) { + autoshare_logic::batch_schedule_notifications(env, ids, creator, ttl_seconds).unwrap(); + } + + // ============================================================================ + // Audit Logging + // ============================================================================ + + /// Returns the full, immutable audit log in append order. + pub fn get_audit_log(env: Env) -> Vec { + autoshare_logic::get_audit_log(env) + } + + /// Returns all audit records for a specific notification identifier. + pub fn get_notification_audit( + env: Env, + notification_id: BytesN<32>, + ) -> Vec { + autoshare_logic::get_audit_records_for_notification(env, notification_id) + } + + /// Records a delivery attempt for a notification in the audit log. + pub fn record_delivery_attempt(env: Env, notification_id: BytesN<32>, actor: Address) { + autoshare_logic::record_delivery_attempt(env, notification_id, actor).unwrap(); + } + + /// Records a delivery failure for a notification in the audit log. + pub fn record_delivery_failure(env: Env, notification_id: BytesN<32>, actor: Address) { + autoshare_logic::record_delivery_failure(env, notification_id, actor).unwrap(); + } + + /// Records that the recipient acknowledged a notification. + pub fn record_acknowledgment(env: Env, notification_id: BytesN<32>, actor: Address) { + autoshare_logic::record_acknowledgment(env, notification_id, actor).unwrap(); + } } #[cfg(test)] @@ -317,4 +367,13 @@ mod tests { #[path = "../tests/expiration_test.rs"] mod expiration_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 payload_validation_test; } diff --git a/contract/contracts/hello-world/src/tests/audit_log_test.rs b/contract/contracts/hello-world/src/tests/audit_log_test.rs new file mode 100644 index 0000000..c58f1da --- /dev/null +++ b/contract/contracts/hello-world/src/tests/audit_log_test.rs @@ -0,0 +1,444 @@ +//! Tests for the on-chain audit logging system (AGENTS.md — Audit Logging). +//! +//! Acceptance criteria verified here: +//! - All lifecycle events are recorded (creation, delivery attempt, delivery +//! failure, acknowledgment, cancellation, expiry). +//! - Audit records are searchable by notification id. +//! - Logs remain immutable after creation (records only grow, never shrink). +//! - An `AuditRecordAppended` event is emitted for every appended record. +//! - Records carry the correct `seq`, `action`, `actor`, and `timestamp`. + +use crate::base::events::{AuditAction, NotificationCategory}; +use crate::test_utils::setup_test_env; +use crate::AutoShareContractClient; + +use soroban_sdk::testutils::{Address as _, Events, Ledger}; +use soroban_sdk::{BytesN, Env, Symbol, TryFromVal, Val, Vec}; + +const ONE_HOUR: u64 = 3_600; + +fn make_id(env: &Env, tag: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = tag; + BytesN::from_array(env, &bytes) +} + +fn set_now(env: &Env, ts: u64) { + env.ledger().set_timestamp(ts); +} + +/// Returns the topics of the most recently emitted event matching `event_name`. +fn topics_of(env: &soroban_sdk::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 +} + +// ============================================================================ +// Creation audit record +// ============================================================================ + +#[test] +fn test_schedule_notification_creates_audit_record() { + 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(&test_env.env, 1_000); + let id = make_id(&test_env.env, 1); + client.schedule_notification(&id, &creator, &ONE_HOUR); + + let records = client.get_audit_log(); + assert_eq!(records.len(), 1); + + let r = records.get(0).unwrap(); + assert_eq!(r.seq, 1); + assert_eq!(r.notification_id, id); + assert_eq!(r.action, AuditAction::Created); + assert_eq!(r.actor, creator); + assert_eq!(r.timestamp, 1_000); +} + +#[test] +fn test_schedule_notification_emits_audit_event() { + 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, 2); + client.schedule_notification(&id, &creator, &ONE_HOUR); + + let topics = + topics_of(&test_env.env, "audit_record_appended").expect("audit event must be emitted"); + + // topics: [0] name, [1] notification_id, [2] action, [3] category + assert_eq!(topics.len(), 4); + + let topic_id = BytesN::<32>::try_from_val(&test_env.env, &topics.get(1).unwrap()).unwrap(); + assert_eq!(topic_id, id); + + let action = AuditAction::try_from_val(&test_env.env, &topics.get(2).unwrap()).unwrap(); + assert_eq!(action, AuditAction::Created); + + let category = + NotificationCategory::try_from_val(&test_env.env, &topics.get(3).unwrap()).unwrap(); + assert_eq!(category, NotificationCategory::Notification); +} + +// ============================================================================ +// Delivery attempt / failure audit records +// ============================================================================ + +#[test] +fn test_delivery_attempt_recorded() { + 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 relay = test_env.users.get(1).unwrap().clone(); + + let id = make_id(&test_env.env, 10); + client.schedule_notification(&id, &creator, &ONE_HOUR); + client.record_delivery_attempt(&id, &relay); + + let records = client.get_notification_audit(&id); + assert_eq!(records.len(), 2); + + let attempt = records.get(1).unwrap(); + assert_eq!(attempt.action, AuditAction::DeliveryAttempt); + assert_eq!(attempt.actor, relay); +} + +#[test] +fn test_delivery_failure_recorded() { + 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 relay = test_env.users.get(1).unwrap().clone(); + + let id = make_id(&test_env.env, 11); + client.schedule_notification(&id, &creator, &ONE_HOUR); + client.record_delivery_attempt(&id, &relay); + client.record_delivery_failure(&id, &relay); + + let records = client.get_notification_audit(&id); + assert_eq!(records.len(), 3); + + let failure = records.get(2).unwrap(); + assert_eq!(failure.action, AuditAction::DeliveryFailed); + assert_eq!(failure.actor, relay); +} + +// ============================================================================ +// Acknowledgment audit record +// ============================================================================ + +#[test] +fn test_acknowledgment_recorded() { + 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 recipient = test_env.users.get(2).unwrap().clone(); + + let id = make_id(&test_env.env, 20); + client.schedule_notification(&id, &creator, &ONE_HOUR); + client.record_acknowledgment(&id, &recipient); + + let records = client.get_notification_audit(&id); + assert_eq!(records.len(), 2); + + let ack = records.get(1).unwrap(); + assert_eq!(ack.action, AuditAction::Acknowledged); + assert_eq!(ack.actor, recipient); +} + +// ============================================================================ +// Cancellation audit record +// ============================================================================ + +#[test] +fn test_cancel_notification_creates_audit_record() { + 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, 30); + client.schedule_notification(&id, &creator, &ONE_HOUR); + client.cancel_notification(&id, &creator); + + let records = client.get_notification_audit(&id); + assert_eq!(records.len(), 2); + + let cancel_record = records.get(1).unwrap(); + assert_eq!(cancel_record.action, AuditAction::Cancelled); + assert_eq!(cancel_record.actor, creator); +} + +// ============================================================================ +// Expiry audit record +// ============================================================================ + +#[test] +fn test_expire_notification_creates_audit_record() { + 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(&test_env.env, 2_000); + let id = make_id(&test_env.env, 40); + client.schedule_notification(&id, &creator, &ONE_HOUR); + + set_now(&test_env.env, 2_000 + ONE_HOUR); + client.expire_notification(&id); + + let records = client.get_notification_audit(&id); + assert_eq!(records.len(), 2); + + let expiry_record = records.get(1).unwrap(); + assert_eq!(expiry_record.action, AuditAction::Expired); +} + +// ============================================================================ +// Full lifecycle: created → delivery attempt → failure → acknowledged → cancelled +// ============================================================================ + +#[test] +fn test_full_lifecycle_audit_trail() { + 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 relay = test_env.users.get(1).unwrap().clone(); + let recipient = test_env.users.get(2).unwrap().clone(); + + set_now(&test_env.env, 500); + let id = make_id(&test_env.env, 50); + + client.schedule_notification(&id, &creator, &ONE_HOUR); + client.record_delivery_attempt(&id, &relay); + client.record_delivery_failure(&id, &relay); + client.record_delivery_attempt(&id, &relay); + client.record_acknowledgment(&id, &recipient); + client.cancel_notification(&id, &creator); + + let records = client.get_notification_audit(&id); + assert_eq!(records.len(), 6); + + let expected_actions = [ + AuditAction::Created, + AuditAction::DeliveryAttempt, + AuditAction::DeliveryFailed, + AuditAction::DeliveryAttempt, + AuditAction::Acknowledged, + AuditAction::Cancelled, + ]; + + for (i, expected) in expected_actions.iter().enumerate() { + let r = records.get(i as u32).unwrap(); + assert_eq!( + &r.action, expected, + "record[{i}] action mismatch: expected {expected:?}, got {:?}", + r.action + ); + } +} + +// ============================================================================ +// Sequence numbers are monotonically increasing +// ============================================================================ + +#[test] +fn test_audit_sequence_numbers_increment() { + 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 relay = test_env.users.get(1).unwrap().clone(); + + let id1 = make_id(&test_env.env, 60); + let id2 = make_id(&test_env.env, 61); + + client.schedule_notification(&id1, &creator, &ONE_HOUR); + client.schedule_notification(&id2, &creator, &ONE_HOUR); + client.record_delivery_attempt(&id1, &relay); + + let log = client.get_audit_log(); + assert_eq!(log.len(), 3); + + // Sequence numbers must be strictly increasing. + for i in 1..log.len() { + let prev = log.get(i - 1).unwrap().seq; + let curr = log.get(i).unwrap().seq; + assert!( + curr > prev, + "seq[{i}]={curr} must be greater than seq[{}]={prev}", + i - 1 + ); + } + + // First seq is 1. + assert_eq!(log.get(0).unwrap().seq, 1); +} + +// ============================================================================ +// Immutability: log only grows, records never change +// ============================================================================ + +#[test] +fn test_audit_log_immutability() { + 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, 70); + client.schedule_notification(&id, &creator, &ONE_HOUR); + + // Snapshot after first write. + let snapshot_seq = client.get_audit_log().get(0).unwrap().seq; + let snapshot_action = client.get_audit_log().get(0).unwrap().action; + + // Add more records. + client.record_delivery_attempt(&id, &creator); + + // First record must be unchanged. + let first = client.get_audit_log().get(0).unwrap(); + assert_eq!(first.seq, snapshot_seq, "seq must not change"); + assert_eq!(first.action, snapshot_action, "action must not change"); + + // Log must have grown. + assert_eq!(client.get_audit_log().len(), 2); +} + +// ============================================================================ +// Searchability: get_audit_records_for_notification filters correctly +// ============================================================================ + +#[test] +fn test_audit_records_filtered_by_notification_id() { + 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_a = make_id(&test_env.env, 80); + let id_b = make_id(&test_env.env, 81); + + client.schedule_notification(&id_a, &creator, &ONE_HOUR); + client.schedule_notification(&id_b, &creator, &ONE_HOUR); + client.record_delivery_attempt(&id_a, &creator); + client.record_delivery_attempt(&id_b, &creator); + client.record_acknowledgment(&id_b, &creator); + + let full_log = client.get_audit_log(); + assert_eq!(full_log.len(), 5); + + // id_a: Created + DeliveryAttempt = 2 records. + let records_a = client.get_notification_audit(&id_a); + assert_eq!(records_a.len(), 2); + assert!(records_a.iter().all(|r| r.notification_id == id_a)); + + // id_b: Created + DeliveryAttempt + Acknowledged = 3 records. + let records_b = client.get_notification_audit(&id_b); + assert_eq!(records_b.len(), 3); + assert!(records_b.iter().all(|r| r.notification_id == id_b)); +} + +#[test] +fn test_audit_records_for_unknown_notification_returns_empty() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let unknown_id = make_id(&test_env.env, 90); + let records = client.get_notification_audit(&unknown_id); + assert_eq!(records.len(), 0); +} + +// ============================================================================ +// Pause guard on mutable audit helpers +// ============================================================================ + +#[test] +fn test_delivery_attempt_blocked_when_paused() { + 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 relay = test_env.users.get(1).unwrap().clone(); + + let id = make_id(&test_env.env, 100); + client.schedule_notification(&id, &creator, &ONE_HOUR); + client.pause(&test_env.admin); + + let result = client.try_record_delivery_attempt(&id, &relay); + assert!(result.is_err(), "delivery attempt must be blocked when paused"); +} + +#[test] +fn test_delivery_failure_blocked_when_paused() { + 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 relay = test_env.users.get(1).unwrap().clone(); + + let id = make_id(&test_env.env, 101); + client.schedule_notification(&id, &creator, &ONE_HOUR); + client.pause(&test_env.admin); + + let result = client.try_record_delivery_failure(&id, &relay); + assert!( + result.is_err(), + "delivery failure must be blocked when paused" + ); +} + +#[test] +fn test_acknowledgment_blocked_when_paused() { + 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 recipient = test_env.users.get(2).unwrap().clone(); + + let id = make_id(&test_env.env, 102); + client.schedule_notification(&id, &creator, &ONE_HOUR); + client.pause(&test_env.admin); + + let result = client.try_record_acknowledgment(&id, &recipient); + assert!( + result.is_err(), + "acknowledgment must be blocked when paused" + ); +} + +// ============================================================================ +// Batch notifications also produce audit records +// ============================================================================ + +#[test] +fn test_batch_schedule_creates_audit_records() { + 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: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + for i in 110u8..=114 { + ids.push_back(make_id(&test_env.env, i)); + ttls.push_back(ONE_HOUR); + } + + client.batch_schedule_notifications(&ids, &creator, &ttls); + + // 5 audit records — one Created per notification. + let log = client.get_audit_log(); + assert_eq!(log.len(), 5); + + for r in log.iter() { + assert_eq!(r.action, AuditAction::Created); + } +} diff --git a/contract/contracts/hello-world/src/tests/batch_notification_test.rs b/contract/contracts/hello-world/src/tests/batch_notification_test.rs new file mode 100644 index 0000000..392a77d --- /dev/null +++ b/contract/contracts/hello-world/src/tests/batch_notification_test.rs @@ -0,0 +1,405 @@ +//! Tests for batch notification creation (AGENTS.md — Batch Notifications). +//! +//! Acceptance criteria verified here: +//! - Multiple notifications can be created in a single transaction. +//! - Invalid recipients / inputs are handled appropriately. +//! - A `BatchNotificationsCreated` summary event is emitted. +//! - Individual `NotificationScheduled` events are emitted for each notification. +//! - The contract is paused-aware. +//! - Edge cases: empty batch, mismatched lengths, duplicate ids, batch too large. + +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::{BytesN, Env, Symbol, TryFromVal, Val, Vec}; + +const ONE_HOUR: u64 = 3_600; + +fn make_id(env: &Env, tag: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = tag; + BytesN::from_array(env, &bytes) +} + +fn set_now(env: &Env, ts: u64) { + env.ledger().set_timestamp(ts); +} + +/// Count how many events named `event_name` were emitted. +fn count_events(env: &soroban_sdk::Env, event_name: &str) -> u32 { + let target = Symbol::new(env, event_name); + let mut n = 0u32; + 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 { + n += 1; + } + } + } + n +} + +/// Returns the topics of the most recently emitted event matching `event_name`. +fn topics_of(env: &soroban_sdk::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 +} + +// ============================================================================ +// Happy-path tests +// ============================================================================ + +#[test] +fn test_batch_creates_all_notifications() { + 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(&test_env.env, 1_000); + + let mut ids: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + for i in 1u8..=5 { + ids.push_back(make_id(&test_env.env, i)); + ttls.push_back(ONE_HOUR); + } + + client.batch_schedule_notifications(&ids, &creator, &ttls); + + // Each notification must be stored and not yet expired. + for i in 1u8..=5 { + let id = make_id(&test_env.env, i); + let stored = client.get_notification(&id); + assert_eq!(stored.creator, creator); + assert_eq!(stored.created_at, 1_000); + assert_eq!(stored.expires_at, 1_000 + ONE_HOUR); + assert!(!client.is_notification_expired(&id)); + } +} + +#[test] +fn test_batch_emits_per_notification_events() { + 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: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + for i in 10u8..=13 { + ids.push_back(make_id(&test_env.env, i)); + ttls.push_back(ONE_HOUR); + } + + client.batch_schedule_notifications(&ids, &creator, &ttls); + + // 4 individual NotificationScheduled events must have been emitted. + assert_eq!(count_events(&test_env.env, "notification_scheduled"), 4); +} + +#[test] +fn test_batch_emits_summary_event() { + 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: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + for i in 20u8..=22 { + ids.push_back(make_id(&test_env.env, i)); + ttls.push_back(ONE_HOUR * 2); + } + + client.batch_schedule_notifications(&ids, &creator, &ttls); + + // The summary event must exist. + assert!( + topics_of(&test_env.env, "batch_notifications_created").is_some(), + "batch_notifications_created event must be emitted" + ); +} + +#[test] +fn test_batch_summary_event_has_notification_category() { + 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: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + for i in 30u8..=31 { + ids.push_back(make_id(&test_env.env, i)); + ttls.push_back(ONE_HOUR); + } + + client.batch_schedule_notifications(&ids, &creator, &ttls); + + let topics = topics_of(&test_env.env, "batch_notifications_created").unwrap(); + // topics: [0] name, [1] creator, [2] category, [3] priority + assert_eq!(topics.len(), 4); + + let category = + NotificationCategory::try_from_val(&test_env.env, &topics.get(2).unwrap()).unwrap(); + assert_eq!(category, NotificationCategory::Notification); + + let priority = + NotificationPriority::try_from_val(&test_env.env, &topics.get(3).unwrap()).unwrap(); + assert_eq!(priority, NotificationPriority::Medium); +} + +#[test] +fn test_batch_single_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 mut ids: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + ids.push_back(make_id(&test_env.env, 40)); + ttls.push_back(ONE_HOUR); + + // A batch of one is valid. + client.batch_schedule_notifications(&ids, &creator, &ttls); + + assert!(client.try_get_notification(&make_id(&test_env.env, 40)).is_ok()); +} + +#[test] +fn test_batch_notifications_expire_correctly() { + 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(&test_env.env, 500); + + let mut ids: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + for i in 50u8..=52 { + ids.push_back(make_id(&test_env.env, i)); + ttls.push_back(ONE_HOUR); + } + + client.batch_schedule_notifications(&ids, &creator, &ttls); + + // Not yet expired. + set_now(&test_env.env, 500 + ONE_HOUR - 1); + for i in 50u8..=52 { + assert!(!client.is_notification_expired(&make_id(&test_env.env, i))); + } + + // At deadline all are expired. + set_now(&test_env.env, 500 + ONE_HOUR); + for i in 50u8..=52 { + assert!(client.is_notification_expired(&make_id(&test_env.env, i))); + } +} + +// ============================================================================ +// Validation / rejection tests +// ============================================================================ + +#[test] +fn test_batch_empty_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 ids: Vec> = Vec::new(&test_env.env); + let ttls: Vec = Vec::new(&test_env.env); + + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + assert!(result.is_err(), "empty batch must be rejected"); +} + +#[test] +fn test_batch_mismatched_lengths_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: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + ids.push_back(make_id(&test_env.env, 60)); + ids.push_back(make_id(&test_env.env, 61)); + ttls.push_back(ONE_HOUR); // Only 1 ttl for 2 ids. + + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + assert!(result.is_err(), "mismatched lengths must be rejected"); +} + +#[test] +fn test_batch_zero_ttl_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: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + ids.push_back(make_id(&test_env.env, 70)); + ttls.push_back(0); // Zero TTL is invalid. + + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + assert!(result.is_err(), "zero TTL in batch must be rejected"); +} + +#[test] +fn test_batch_duplicate_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 dup_id = make_id(&test_env.env, 80); + + let mut ids: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + ids.push_back(dup_id.clone()); + ids.push_back(dup_id.clone()); // Duplicate. + ttls.push_back(ONE_HOUR); + ttls.push_back(ONE_HOUR); + + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + assert!(result.is_err(), "duplicate ids in batch must be rejected"); +} + +#[test] +fn test_batch_id_already_scheduled_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, 90); + + // Schedule the id individually first. + client.schedule_notification(&id, &creator, &ONE_HOUR); + + // Now try to include it in a batch — must be rejected (AlreadyExists). + let mut ids: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + ids.push_back(id); + ttls.push_back(ONE_HOUR); + + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + assert!( + result.is_err(), + "batch must be rejected when an id is already scheduled" + ); +} + +#[test] +fn test_batch_all_or_nothing_on_validation_failure() { + // If any entry in the batch fails validation, none should be persisted. + 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 good_id = make_id(&test_env.env, 100); + let bad_id = make_id(&test_env.env, 101); + + // Pre-schedule the bad id so it will collide. + client.schedule_notification(&bad_id, &creator, &ONE_HOUR); + + let mut ids: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + ids.push_back(good_id.clone()); + ids.push_back(bad_id.clone()); // Will cause AlreadyExists. + ttls.push_back(ONE_HOUR); + ttls.push_back(ONE_HOUR); + + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + assert!(result.is_err(), "batch must fail"); + + // The good_id must NOT have been persisted (all-or-nothing). + assert!( + client.try_get_notification(&good_id).is_err(), + "good_id must not be stored when batch is rejected" + ); +} + +#[test] +fn test_batch_exceeding_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(); + + // MAX_BATCH_SIZE is 50; try 51. + let mut ids: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + for i in 0u8..51 { + let mut bytes = [0u8; 32]; + bytes[0] = i; + bytes[1] = 200; // Namespace to avoid collision with other tests. + ids.push_back(BytesN::from_array(&test_env.env, &bytes)); + ttls.push_back(ONE_HOUR); + } + + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + assert!(result.is_err(), "batch exceeding max size must be rejected"); +} + +#[test] +fn test_batch_exactly_max_size_accepted() { + 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(); + + // MAX_BATCH_SIZE is 50 — exactly 50 entries must succeed. + let mut ids: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + for i in 0u8..50 { + let mut bytes = [0u8; 32]; + bytes[0] = i; + bytes[1] = 201; // Namespace to avoid collision with other tests. + ids.push_back(BytesN::from_array(&test_env.env, &bytes)); + ttls.push_back(ONE_HOUR); + } + + // Must not panic. + client.batch_schedule_notifications(&ids, &creator, &ttls); + + // Summary event must be present. + assert!( + topics_of(&test_env.env, "batch_notifications_created").is_some(), + "summary event must be emitted for max-size batch" + ); +} + +// ============================================================================ +// Pause guard +// ============================================================================ + +#[test] +fn test_batch_blocked_when_contract_paused() { + 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(); + + client.pause(&test_env.admin); + + let mut ids: Vec> = Vec::new(&test_env.env); + let mut ttls: Vec = Vec::new(&test_env.env); + ids.push_back(make_id(&test_env.env, 110)); + ttls.push_back(ONE_HOUR); + + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + assert!( + result.is_err(), + "batch must be rejected while contract is paused" + ); +} diff --git a/contract/contracts/hello-world/src/tests/payload_validation_test.rs b/contract/contracts/hello-world/src/tests/payload_validation_test.rs new file mode 100644 index 0000000..dd2c155 --- /dev/null +++ b/contract/contracts/hello-world/src/tests/payload_validation_test.rs @@ -0,0 +1,641 @@ +//! Tests for payload validation logic (AGENTS.md — payload validation / event +//! type filtering). +//! +//! Acceptance criteria verified here: +//! - Invalid payloads are rejected with appropriate errors. +//! - Edge cases (boundary values, empty inputs, overflow) are covered. +//! - Event category/priority metadata is present on every emitted event so +//! off-chain consumers can identify notification types directly. + +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::{Address, BytesN, Env, String, Symbol, TryFromVal, Vec}; + +// ── helpers ───────────────────────────────────────────────────────────────── + +fn make_id(env: &Env, tag: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = tag; + BytesN::from_array(env, &bytes) +} + +/// Returns the topic list of the most recently emitted event whose first topic +/// matches `event_name`. +fn topics_of(env: &Env, event_name: &str) -> Option> { + use soroban_sdk::Val; + 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 +} + +fn category_of(env: &Env, event_name: &str) -> Option { + let topics = topics_of(env, event_name)?; + let n = topics.len(); + if n < 2 { + return None; + } + NotificationCategory::try_from_val(env, &topics.get(n - 2)?).ok() +} + +fn priority_of(env: &Env, event_name: &str) -> Option { + let topics = topics_of(env, event_name)?; + let last = topics.last()?; + NotificationPriority::try_from_val(env, &last).ok() +} + +// ============================================================================ +// Invalid payload rejection — group creation +// ============================================================================ + +#[test] +fn test_create_rejects_zero_usage_count() { + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + let id = make_id(&test_env.env, 1); + + crate::test_utils::mint_tokens(&test_env.env, &token, &creator, 1_000_000); + let result = client.try_create( + &id, + &String::from_str(&test_env.env, "Test"), + &creator, + &0u32, + &token, + ); + assert!(result.is_err(), "zero usage count must be rejected"); +} + +#[test] +fn test_create_rejects_name_over_max_length() { + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + + crate::test_utils::mint_tokens(&test_env.env, &token, &creator, 1_000_000); + + // Exactly MAX_NAME_LENGTH (100) — must succeed. + let ok_id = make_id(&test_env.env, 2); + let ok_name = String::from_str(&test_env.env, &"X".repeat(100)); + client.create(&ok_id, &ok_name, &creator, &1u32, &token); + + // 101 chars — must fail. + let long_id = make_id(&test_env.env, 3); + let long_name = String::from_str(&test_env.env, &"X".repeat(101)); + let result = client.try_create(&long_id, &long_name, &creator, &1u32, &token); + assert!(result.is_err(), "name > 100 chars must be rejected"); +} + +#[test] +fn test_create_rejects_unsupported_token() { + 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, 4); + + let bad_token = crate::test_utils::deploy_mock_token( + &test_env.env, + &String::from_str(&test_env.env, "Bad"), + &String::from_str(&test_env.env, "BAD"), + ); + crate::test_utils::mint_tokens(&test_env.env, &bad_token, &creator, 1_000_000); + + let result = client.try_create( + &id, + &String::from_str(&test_env.env, "T"), + &creator, + &1u32, + &bad_token, + ); + assert!(result.is_err(), "unsupported token must be rejected"); +} + +#[test] +fn test_create_rejects_duplicate_id() { + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + + // Create the group once — succeeds. + create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 1, + &token, + ); + + // A second create with the same id bytes must fail. + crate::test_utils::mint_tokens(&test_env.env, &token, &creator, 1_000_000); + let mut dup_id_bytes = [0u8; 32]; + dup_id_bytes[0..4].copy_from_slice(&1u32.to_be_bytes()); + let dup_id = BytesN::from_array(&test_env.env, &dup_id_bytes); + + let result = client.try_create( + &dup_id, + &String::from_str(&test_env.env, "Dup"), + &creator, + &1u32, + &token, + ); + assert!(result.is_err(), "duplicate id must be rejected"); +} + +// ============================================================================ +// Invalid payload rejection — notification scheduling +// ============================================================================ + +#[test] +fn test_schedule_rejects_zero_ttl() { + 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, 10); + + let result = client.try_schedule_notification(&id, &creator, &0u64); + assert!(result.is_err(), "zero TTL must be rejected"); +} + +#[test] +fn test_schedule_rejects_duplicate_id() { + 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, 11); + + client.schedule_notification(&id, &creator, &3_600u64); + let result = client.try_schedule_notification(&id, &creator, &3_600u64); + assert!(result.is_err(), "duplicate notification id must be rejected"); +} + +#[test] +fn test_schedule_rejects_overflow_ttl() { + 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, 12); + + // Set a non-zero timestamp so u64::MAX + timestamp overflows. + use soroban_sdk::testutils::Ledger; + test_env.env.ledger().set_timestamp(1_000); + + let result = client.try_schedule_notification(&id, &creator, &u64::MAX); + assert!(result.is_err(), "overflow TTL must be rejected"); +} + +// ============================================================================ +// Invalid payload rejection — member management +// ============================================================================ + +#[test] +fn test_update_members_rejects_empty_list() { + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 1, + &token, + ); + + let result = client.try_update_members(&id, &creator, &Vec::new(&test_env.env)); + assert!(result.is_err(), "empty member list must be rejected"); +} + +#[test] +fn test_update_members_rejects_percentages_not_summing_to_100() { + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 1, + &token, + ); + + let mut bad_members: Vec = Vec::new(&test_env.env); + bad_members.push_back(crate::base::types::GroupMember { + address: Address::generate(&test_env.env), + percentage: 60, + }); + // Sum = 60, not 100. + let result = client.try_update_members(&id, &creator, &bad_members); + assert!( + result.is_err(), + "member percentages not summing to 100 must be rejected" + ); +} + +#[test] +fn test_update_members_rejects_duplicate_addresses() { + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 1, + &token, + ); + + let dup = Address::generate(&test_env.env); + let mut bad_members: Vec = Vec::new(&test_env.env); + bad_members.push_back(crate::base::types::GroupMember { + address: dup.clone(), + percentage: 50, + }); + bad_members.push_back(crate::base::types::GroupMember { + address: dup.clone(), + percentage: 50, + }); + + let result = client.try_update_members(&id, &creator, &bad_members); + assert!(result.is_err(), "duplicate member addresses must be rejected"); +} + +#[test] +fn test_update_members_rejects_over_max_members() { + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 1, + &token, + ); + + let mut members: Vec = Vec::new(&test_env.env); + for _ in 0..51u32 { + members.push_back(crate::base::types::GroupMember { + address: Address::generate(&test_env.env), + percentage: 1, + }); + } + let result = client.try_update_members(&id, &creator, &members); + assert!(result.is_err(), "51 members must be rejected (max is 50)"); +} + +// ============================================================================ +// Edge cases — boundary values +// ============================================================================ + +#[test] +fn test_usage_fee_boundary_one_is_valid() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + // Fee of 1 is the minimum valid value. + client.set_usage_fee(&1u32, &test_env.admin); + assert_eq!(client.get_usage_fee(), 1u32); +} + +#[test] +fn test_usage_fee_zero_is_rejected() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + let result = client.try_set_usage_fee(&0u32, &test_env.admin); + assert!(result.is_err(), "usage fee of 0 must be rejected"); +} + +#[test] +fn test_single_member_at_100_percent_is_valid() { + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 1, + &token, + ); + + let mut members: Vec = Vec::new(&test_env.env); + members.push_back(crate::base::types::GroupMember { + address: Address::generate(&test_env.env), + percentage: 100, + }); + // Must not panic. + client.update_members(&id, &creator, &members); +} + +#[test] +fn test_ttl_of_one_second_is_valid() { + 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); + + // TTL = 1 second is the minimum valid value. + client.schedule_notification(&id, &creator, &1u64); + let stored = client.get_notification(&id); + assert_eq!(stored.expires_at, stored.created_at + 1); +} + +// ============================================================================ +// Event metadata — every event carries category and priority +// ============================================================================ + +#[test] +fn test_every_event_carries_category_and_priority() { + // Verify that each event type carries category and priority topics. + // We check each event immediately after the action that produces it, + // so we never miss an event due to env accumulation ordering. + + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + + // --- autoshare_created --- + create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 1, + &token, + ); + assert!( + category_of(&test_env.env, "autoshare_created").is_some(), + "autoshare_created must carry a NotificationCategory topic" + ); + assert!( + priority_of(&test_env.env, "autoshare_created").is_some(), + "autoshare_created must carry a NotificationPriority topic" + ); + + // --- notification_scheduled --- + let id = make_id(&test_env.env, 30); + client.schedule_notification(&id, &creator, &3_600u64); + assert!( + category_of(&test_env.env, "notification_scheduled").is_some(), + "notification_scheduled must carry a NotificationCategory topic" + ); + assert!( + priority_of(&test_env.env, "notification_scheduled").is_some(), + "notification_scheduled must carry a NotificationPriority topic" + ); + + // --- scheduled_notification_cancelled --- + client.cancel_notification(&id, &creator); + assert!( + category_of(&test_env.env, "scheduled_notification_cancelled").is_some(), + "scheduled_notification_cancelled must carry a NotificationCategory topic" + ); + assert!( + priority_of(&test_env.env, "scheduled_notification_cancelled").is_some(), + "scheduled_notification_cancelled must carry a NotificationPriority topic" + ); + + // --- contract_paused --- + client.pause(&test_env.admin); + assert!( + category_of(&test_env.env, "contract_paused").is_some(), + "contract_paused must carry a NotificationCategory topic" + ); + assert!( + priority_of(&test_env.env, "contract_paused").is_some(), + "contract_paused must carry a NotificationPriority topic" + ); + + // --- contract_unpaused --- + client.unpause(&test_env.admin); + assert!( + category_of(&test_env.env, "contract_unpaused").is_some(), + "contract_unpaused must carry a NotificationCategory topic" + ); + assert!( + priority_of(&test_env.env, "contract_unpaused").is_some(), + "contract_unpaused must carry a NotificationPriority topic" + ); +} + +#[test] +fn test_group_events_have_group_category() { + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 1, + &token, + ); + + assert_eq!( + category_of(&test_env.env, "autoshare_created"), + Some(NotificationCategory::Group) + ); + + client.deactivate_group(&id, &creator); + assert_eq!( + category_of(&test_env.env, "group_deactivated"), + Some(NotificationCategory::Group) + ); + + client.activate_group(&id, &creator); + assert_eq!( + category_of(&test_env.env, "group_activated"), + Some(NotificationCategory::Group) + ); +} + +#[test] +fn test_admin_events_have_admin_category() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + + client.pause(&test_env.admin); + assert_eq!( + category_of(&test_env.env, "contract_paused"), + Some(NotificationCategory::Admin) + ); + assert_eq!( + priority_of(&test_env.env, "contract_paused"), + Some(NotificationPriority::High) + ); + + client.unpause(&test_env.admin); + assert_eq!( + category_of(&test_env.env, "contract_unpaused"), + Some(NotificationCategory::Admin) + ); +} + +#[test] +fn test_notification_events_have_notification_category() { + 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, 40); + client.schedule_notification(&id, &creator, &3_600u64); + + assert_eq!( + category_of(&test_env.env, "notification_scheduled"), + Some(NotificationCategory::Notification) + ); + + client.cancel_notification(&id, &creator); + assert_eq!( + category_of(&test_env.env, "scheduled_notification_cancelled"), + Some(NotificationCategory::Notification) + ); +} + +#[test] +fn test_financial_events_have_financial_category() { + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + + // Fund the contract by creating a group. + create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 1, + &token, + ); + + let recipient = Address::generate(&test_env.env); + client.withdraw(&test_env.admin, &token, &1i128, &recipient); + + assert_eq!( + category_of(&test_env.env, "withdrawal"), + Some(NotificationCategory::Financial) + ); + assert_eq!( + priority_of(&test_env.env, "withdrawal"), + Some(NotificationPriority::High) + ); +} + +#[test] +fn test_admin_transfer_has_critical_priority() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let new_admin = Address::generate(&test_env.env); + + client.transfer_admin(&test_env.admin, &new_admin); + + assert_eq!( + priority_of(&test_env.env, "admin_transferred"), + Some(NotificationPriority::Critical) + ); +} + +// ============================================================================ +// Consumers can filter events by notification type +// ============================================================================ + +#[test] +fn test_consumer_can_filter_by_category() { + 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 token = test_env.mock_tokens.get(0).unwrap().clone(); + + // 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 { + return None; + } + NotificationCategory::try_from_val(env, &topics.get(n - 2)?).ok() + }; + + let mut group_events = 0u32; + let mut admin_events = 0u32; + let mut notification_events = 0u32; + let mut financial_events = 0u32; + + let mut tally = |env: &Env| match latest_category(env) { + Some(NotificationCategory::Group) => group_events += 1, + Some(NotificationCategory::Admin) => admin_events += 1, + Some(NotificationCategory::Notification) => notification_events += 1, + Some(NotificationCategory::Financial) => financial_events += 1, + None => {} + }; + + // Group event. + create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &Vec::new(&test_env.env), + 1, + &token, + ); + tally(&test_env.env); + + // Admin events. + client.pause(&test_env.admin); + tally(&test_env.env); + client.unpause(&test_env.admin); + tally(&test_env.env); + + // Notification events (schedule emits audit_record_appended + notification_scheduled). + let id = make_id(&test_env.env, 50); + client.schedule_notification(&id, &creator, &3_600u64); + tally(&test_env.env); // last event emitted = notification_scheduled (Notification) + + // Financial event. + let recipient = Address::generate(&test_env.env); + client.withdraw(&test_env.admin, &token, &1i128, &recipient); + tally(&test_env.env); + + assert_eq!(group_events, 1, "one Group event expected"); + assert_eq!(admin_events, 2, "two Admin events expected (pause + unpause)"); + assert!(notification_events >= 1, "at least one Notification event"); + assert_eq!(financial_events, 1, "one Financial event expected"); +} From 64837132f702f0f203afc611d99bd2cd860dadec Mon Sep 17 00:00:00 2001 From: ScriptedBro Date: Thu, 25 Jun 2026 13:48:30 +0100 Subject: [PATCH 2/4] Fix CI check failures --- .../hello-world/src/autoshare_logic.rs | 7 +------ .../hello-world/src/tests/audit_log_test.rs | 5 ++++- .../src/tests/batch_notification_test.rs | 4 +++- .../src/tests/payload_validation_test.rs | 15 ++++++++++++--- .../hello-world/src/tests/revocation_test.rs | 18 +++++++++++------- .../notification-scheduler-refactored.test.ts | 17 ++++++++++++----- 6 files changed, 43 insertions(+), 23 deletions(-) diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index ff71f05..8dda0da 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1234,12 +1234,7 @@ fn append_audit_record( ) { // Increment sequence counter. let seq_key = DataKey::AuditSeq; - let seq: u64 = env - .storage() - .instance() - .get(&seq_key) - .unwrap_or(0u64) - + 1; + let seq: u64 = env.storage().instance().get(&seq_key).unwrap_or(0u64) + 1; env.storage().instance().set(&seq_key, &seq); let timestamp = env.ledger().timestamp(); 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 c58f1da..1a94a83 100644 --- a/contract/contracts/hello-world/src/tests/audit_log_test.rs +++ b/contract/contracts/hello-world/src/tests/audit_log_test.rs @@ -376,7 +376,10 @@ fn test_delivery_attempt_blocked_when_paused() { client.pause(&test_env.admin); let result = client.try_record_delivery_attempt(&id, &relay); - assert!(result.is_err(), "delivery attempt must be blocked when paused"); + assert!( + result.is_err(), + "delivery attempt must be blocked when paused" + ); } #[test] 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 392a77d..dd9c8ca 100644 --- a/contract/contracts/hello-world/src/tests/batch_notification_test.rs +++ b/contract/contracts/hello-world/src/tests/batch_notification_test.rs @@ -178,7 +178,9 @@ fn test_batch_single_notification() { // A batch of one is valid. client.batch_schedule_notifications(&ids, &creator, &ttls); - assert!(client.try_get_notification(&make_id(&test_env.env, 40)).is_ok()); + assert!(client + .try_get_notification(&make_id(&test_env.env, 40)) + .is_ok()); } #[test] 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 dd2c155..e249a37 100644 --- a/contract/contracts/hello-world/src/tests/payload_validation_test.rs +++ b/contract/contracts/hello-world/src/tests/payload_validation_test.rs @@ -182,7 +182,10 @@ fn test_schedule_rejects_duplicate_id() { client.schedule_notification(&id, &creator, &3_600u64); let result = client.try_schedule_notification(&id, &creator, &3_600u64); - assert!(result.is_err(), "duplicate notification id must be rejected"); + assert!( + result.is_err(), + "duplicate notification id must be rejected" + ); } #[test] @@ -281,7 +284,10 @@ fn test_update_members_rejects_duplicate_addresses() { }); let result = client.try_update_members(&id, &creator, &bad_members); - assert!(result.is_err(), "duplicate member addresses must be rejected"); + assert!( + result.is_err(), + "duplicate member addresses must be rejected" + ); } #[test] @@ -635,7 +641,10 @@ fn test_consumer_can_filter_by_category() { tally(&test_env.env); assert_eq!(group_events, 1, "one Group event expected"); - assert_eq!(admin_events, 2, "two Admin events expected (pause + unpause)"); + assert_eq!( + admin_events, 2, + "two Admin events expected (pause + unpause)" + ); assert!(notification_events >= 1, "at least one Notification event"); assert_eq!(financial_events, 1, "one Financial event expected"); } diff --git a/contract/contracts/hello-world/src/tests/revocation_test.rs b/contract/contracts/hello-world/src/tests/revocation_test.rs index 8867b53..6d881b9 100644 --- a/contract/contracts/hello-world/src/tests/revocation_test.rs +++ b/contract/contracts/hello-world/src/tests/revocation_test.rs @@ -118,7 +118,8 @@ fn test_revoke_notification_emits_event() { set_now(&test_env.env, 2_000); client.revoke_notification(&id, &creator); - let topics = topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); + let topics = + topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); // [0] name, [1] notification_id, [2] revoked_by, [3] category, [4] priority. assert_eq!(topics.len(), 5); @@ -327,12 +328,14 @@ fn test_revoke_event_has_high_priority() { set_now(&test_env.env, 2_000); client.revoke_notification(&id, &creator); - let topics = topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); + let topics = + topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); // Last topic is priority let priority_topic = topics.last().unwrap(); - let priority = crate::base::events::NotificationPriority::try_from_val(&test_env.env, &priority_topic) - .expect("priority should be extractable"); - + let priority = + crate::base::events::NotificationPriority::try_from_val(&test_env.env, &priority_topic) + .expect("priority should be extractable"); + assert_eq!(priority, crate::base::events::NotificationPriority::High); } @@ -349,12 +352,13 @@ fn test_revoke_event_has_notification_category() { set_now(&test_env.env, 2_000); client.revoke_notification(&id, &creator); - let topics = topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); + let topics = + topics_of(&test_env.env, "notification_revoked").expect("revocation event must be emitted"); // Second to last topic is category let n = topics.len(); let category_topic = topics.get(n - 2).unwrap(); let category = NotificationCategory::try_from_val(&test_env.env, &category_topic) .expect("category should be extractable"); - + assert_eq!(category, NotificationCategory::Notification); } diff --git a/listener/src/tests/notification-scheduler-refactored.test.ts b/listener/src/tests/notification-scheduler-refactored.test.ts index 51f5897..bce2ba2 100644 --- a/listener/src/tests/notification-scheduler-refactored.test.ts +++ b/listener/src/tests/notification-scheduler-refactored.test.ts @@ -321,12 +321,19 @@ describe('NotificationScheduler (Refactored)', () => { .forImmediateExecution() .build() ); - await repository.fetchAndLockPendingNotifications('processor-1', 30000, 10); const pastLock = NotificationFixtureBuilder.dates.past(1000); - await db.run('UPDATE scheduled_notifications SET lock_expires_at = ? WHERE id = ?', [ - pastLock.toISOString(), - staleId, - ]); + await db.run( + `UPDATE scheduled_notifications + SET status = ?, processor_id = ?, lock_expires_at = ?, processing_started_at = ? + WHERE id = ?`, + [ + NotificationStatus.PROCESSING, + 'processor-1', + pastLock.toISOString(), + pastLock.toISOString(), + staleId, + ] + ); // Get stats BEFORE recovery const stats = await repository.getStats(); From 64ac788fb4ea06cd4c52057f88a978d1aba9115c Mon Sep 17 00:00:00 2001 From: ScriptedBro Date: Fri, 26 Jun 2026 19:00:53 +0100 Subject: [PATCH 3/4] Fix merge conflicts and update tests to use new schedule_notification signature with title --- .../hello-world/src/autoshare_logic.rs | 9 ++- contract/contracts/hello-world/src/lib.rs | 3 +- .../hello-world/src/tests/audit_log_test.rs | 42 ++++++----- .../src/tests/batch_notification_test.rs | 71 ++++++++++++++----- .../src/tests/payload_validation_test.rs | 20 +++--- 5 files changed, 99 insertions(+), 46 deletions(-) diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index 6741e6e..7dcd09a 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1174,6 +1174,7 @@ pub fn batch_schedule_notifications( ids: Vec>, creator: Address, ttl_seconds: Vec, + titles: Vec, ) -> Result<(), Error> { creator.require_auth(); @@ -1189,7 +1190,7 @@ pub fn batch_schedule_notifications( } // Lengths must match. - if count != ttl_seconds.len() { + if count != ttl_seconds.len() || count != titles.len() { return Err(Error::InvalidInput); } @@ -1209,6 +1210,10 @@ pub fn batch_schedule_notifications( return Err(Error::InvalidExpirationDuration); } let id = ids.get(i).unwrap(); + let title = titles.get(i).unwrap(); + if title.is_empty() { + return Err(Error::InvalidInput); + } // Check for intra-batch duplicates. for seen in seen_in_batch.iter() { @@ -1232,6 +1237,7 @@ pub fn batch_schedule_notifications( for i in 0..count { let ttl = ttl_seconds.get(i).unwrap(); let id = ids.get(i).unwrap(); + let title = titles.get(i).unwrap(); let expires_at = created_at + ttl; let notification = ScheduledNotification { @@ -1241,6 +1247,7 @@ pub fn batch_schedule_notifications( expires_at, revoked_by: None, revoked_at: None, + title, }; let key = DataKey::ScheduledNotification(id.clone()); env.storage().persistent().set(&key, ¬ification); diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index 202e8a0..be1ae02 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -401,8 +401,9 @@ impl AutoShareContract { ids: Vec>, creator: Address, ttl_seconds: Vec, + titles: Vec, ) { - autoshare_logic::batch_schedule_notifications(env, ids, creator, ttl_seconds).unwrap(); + autoshare_logic::batch_schedule_notifications(env, ids, creator, ttl_seconds, titles).unwrap(); } // ============================================================================ 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 1a94a83..540be2b 100644 --- a/contract/contracts/hello-world/src/tests/audit_log_test.rs +++ b/contract/contracts/hello-world/src/tests/audit_log_test.rs @@ -13,10 +13,14 @@ use crate::test_utils::setup_test_env; use crate::AutoShareContractClient; use soroban_sdk::testutils::{Address as _, Events, Ledger}; -use soroban_sdk::{BytesN, Env, Symbol, TryFromVal, Val, Vec}; +use soroban_sdk::{BytesN, Env, String, Symbol, TryFromVal, Val, Vec}; const ONE_HOUR: u64 = 3_600; +fn make_title(env: &Env) -> soroban_sdk::String { + soroban_sdk::String::from_str(env, "Test Notification") +} + fn make_id(env: &Env, tag: u8) -> BytesN<32> { let mut bytes = [0u8; 32]; bytes[0] = tag; @@ -57,7 +61,7 @@ fn test_schedule_notification_creates_audit_record() { set_now(&test_env.env, 1_000); let id = make_id(&test_env.env, 1); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); let records = client.get_audit_log(); assert_eq!(records.len(), 1); @@ -77,7 +81,7 @@ fn test_schedule_notification_emits_audit_event() { let creator = test_env.users.get(0).unwrap().clone(); let id = make_id(&test_env.env, 2); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); let topics = topics_of(&test_env.env, "audit_record_appended").expect("audit event must be emitted"); @@ -108,7 +112,7 @@ fn test_delivery_attempt_recorded() { let relay = test_env.users.get(1).unwrap().clone(); let id = make_id(&test_env.env, 10); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); client.record_delivery_attempt(&id, &relay); let records = client.get_notification_audit(&id); @@ -127,7 +131,7 @@ fn test_delivery_failure_recorded() { let relay = test_env.users.get(1).unwrap().clone(); let id = make_id(&test_env.env, 11); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); client.record_delivery_attempt(&id, &relay); client.record_delivery_failure(&id, &relay); @@ -151,7 +155,7 @@ fn test_acknowledgment_recorded() { let recipient = test_env.users.get(2).unwrap().clone(); let id = make_id(&test_env.env, 20); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); client.record_acknowledgment(&id, &recipient); let records = client.get_notification_audit(&id); @@ -173,7 +177,7 @@ fn test_cancel_notification_creates_audit_record() { let creator = test_env.users.get(0).unwrap().clone(); let id = make_id(&test_env.env, 30); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); client.cancel_notification(&id, &creator); let records = client.get_notification_audit(&id); @@ -196,7 +200,7 @@ fn test_expire_notification_creates_audit_record() { set_now(&test_env.env, 2_000); let id = make_id(&test_env.env, 40); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); set_now(&test_env.env, 2_000 + ONE_HOUR); client.expire_notification(&id); @@ -223,7 +227,7 @@ fn test_full_lifecycle_audit_trail() { set_now(&test_env.env, 500); let id = make_id(&test_env.env, 50); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); client.record_delivery_attempt(&id, &relay); client.record_delivery_failure(&id, &relay); client.record_delivery_attempt(&id, &relay); @@ -266,8 +270,8 @@ fn test_audit_sequence_numbers_increment() { let id1 = make_id(&test_env.env, 60); let id2 = make_id(&test_env.env, 61); - client.schedule_notification(&id1, &creator, &ONE_HOUR); - client.schedule_notification(&id2, &creator, &ONE_HOUR); + client.schedule_notification(&id1, &creator, &ONE_HOUR, &make_title(&test_env.env)); + client.schedule_notification(&id2, &creator, &ONE_HOUR, &make_title(&test_env.env)); client.record_delivery_attempt(&id1, &relay); let log = client.get_audit_log(); @@ -299,7 +303,7 @@ fn test_audit_log_immutability() { let creator = test_env.users.get(0).unwrap().clone(); let id = make_id(&test_env.env, 70); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); // Snapshot after first write. let snapshot_seq = client.get_audit_log().get(0).unwrap().seq; @@ -330,8 +334,8 @@ fn test_audit_records_filtered_by_notification_id() { 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); - client.schedule_notification(&id_b, &creator, &ONE_HOUR); + client.schedule_notification(&id_a, &creator, &ONE_HOUR, &make_title(&test_env.env)); + client.schedule_notification(&id_b, &creator, &ONE_HOUR, &make_title(&test_env.env)); client.record_delivery_attempt(&id_a, &creator); client.record_delivery_attempt(&id_b, &creator); client.record_acknowledgment(&id_b, &creator); @@ -372,7 +376,7 @@ fn test_delivery_attempt_blocked_when_paused() { let relay = test_env.users.get(1).unwrap().clone(); let id = make_id(&test_env.env, 100); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); client.pause(&test_env.admin); let result = client.try_record_delivery_attempt(&id, &relay); @@ -390,7 +394,7 @@ fn test_delivery_failure_blocked_when_paused() { let relay = test_env.users.get(1).unwrap().clone(); let id = make_id(&test_env.env, 101); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); client.pause(&test_env.admin); let result = client.try_record_delivery_failure(&id, &relay); @@ -408,7 +412,7 @@ fn test_acknowledgment_blocked_when_paused() { let recipient = test_env.users.get(2).unwrap().clone(); let id = make_id(&test_env.env, 102); - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); client.pause(&test_env.admin); let result = client.try_record_acknowledgment(&id, &recipient); @@ -430,12 +434,14 @@ fn test_batch_schedule_creates_audit_records() { let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); for i in 110u8..=114 { ids.push_back(make_id(&test_env.env, i)); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); } - client.batch_schedule_notifications(&ids, &creator, &ttls); + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); // 5 audit records — one Created per notification. let log = client.get_audit_log(); 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 dd9c8ca..ef8d4cc 100644 --- a/contract/contracts/hello-world/src/tests/batch_notification_test.rs +++ b/contract/contracts/hello-world/src/tests/batch_notification_test.rs @@ -13,10 +13,14 @@ use crate::test_utils::setup_test_env; use crate::AutoShareContractClient; use soroban_sdk::testutils::{Address as _, Events, Ledger}; -use soroban_sdk::{BytesN, Env, Symbol, TryFromVal, Val, Vec}; +use soroban_sdk::{BytesN, Env, String, Symbol, TryFromVal, Val, Vec}; const ONE_HOUR: u64 = 3_600; +fn make_title(env: &Env) -> String { + String::from_str(env, "Test Notification") +} + fn make_id(env: &Env, tag: u8) -> BytesN<32> { let mut bytes = [0u8; 32]; bytes[0] = tag; @@ -77,12 +81,14 @@ fn test_batch_creates_all_notifications() { let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); for i in 1u8..=5 { ids.push_back(make_id(&test_env.env, i)); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); } - client.batch_schedule_notifications(&ids, &creator, &ttls); + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); // Each notification must be stored and not yet expired. for i in 1u8..=5 { @@ -103,12 +109,14 @@ fn test_batch_emits_per_notification_events() { let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); for i in 10u8..=13 { ids.push_back(make_id(&test_env.env, i)); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); } - client.batch_schedule_notifications(&ids, &creator, &ttls); + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); // 4 individual NotificationScheduled events must have been emitted. assert_eq!(count_events(&test_env.env, "notification_scheduled"), 4); @@ -122,12 +130,14 @@ fn test_batch_emits_summary_event() { let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); for i in 20u8..=22 { ids.push_back(make_id(&test_env.env, i)); ttls.push_back(ONE_HOUR * 2); + titles.push_back(make_title(&test_env.env)); } - client.batch_schedule_notifications(&ids, &creator, &ttls); + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); // The summary event must exist. assert!( @@ -144,12 +154,14 @@ fn test_batch_summary_event_has_notification_category() { let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); for i in 30u8..=31 { ids.push_back(make_id(&test_env.env, i)); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); } - client.batch_schedule_notifications(&ids, &creator, &ttls); + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); let topics = topics_of(&test_env.env, "batch_notifications_created").unwrap(); // topics: [0] name, [1] creator, [2] category, [3] priority @@ -172,11 +184,13 @@ fn test_batch_single_notification() { let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); ids.push_back(make_id(&test_env.env, 40)); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); // A batch of one is valid. - client.batch_schedule_notifications(&ids, &creator, &ttls); + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); assert!(client .try_get_notification(&make_id(&test_env.env, 40)) @@ -193,12 +207,14 @@ fn test_batch_notifications_expire_correctly() { let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); for i in 50u8..=52 { ids.push_back(make_id(&test_env.env, i)); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); } - client.batch_schedule_notifications(&ids, &creator, &ttls); + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); // Not yet expired. set_now(&test_env.env, 500 + ONE_HOUR - 1); @@ -225,8 +241,9 @@ fn test_batch_empty_ids_rejected() { let ids: Vec> = Vec::new(&test_env.env); let ttls: Vec = Vec::new(&test_env.env); + let titles: Vec = Vec::new(&test_env.env); - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); assert!(result.is_err(), "empty batch must be rejected"); } @@ -238,11 +255,13 @@ fn test_batch_mismatched_lengths_rejected() { let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); ids.push_back(make_id(&test_env.env, 60)); ids.push_back(make_id(&test_env.env, 61)); ttls.push_back(ONE_HOUR); // Only 1 ttl for 2 ids. + titles.push_back(make_title(&test_env.env)); - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); assert!(result.is_err(), "mismatched lengths must be rejected"); } @@ -254,10 +273,12 @@ fn test_batch_zero_ttl_rejected() { let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); ids.push_back(make_id(&test_env.env, 70)); ttls.push_back(0); // Zero TTL is invalid. + titles.push_back(make_title(&test_env.env)); - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); assert!(result.is_err(), "zero TTL in batch must be rejected"); } @@ -271,12 +292,15 @@ fn test_batch_duplicate_id_rejected() { let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); ids.push_back(dup_id.clone()); ids.push_back(dup_id.clone()); // Duplicate. ttls.push_back(ONE_HOUR); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); + titles.push_back(make_title(&test_env.env)); - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); assert!(result.is_err(), "duplicate ids in batch must be rejected"); } @@ -289,15 +313,17 @@ fn test_batch_id_already_scheduled_rejected() { let id = make_id(&test_env.env, 90); // Schedule the id individually first. - client.schedule_notification(&id, &creator, &ONE_HOUR); + client.schedule_notification(&id, &creator, &ONE_HOUR, &make_title(&test_env.env)); // Now try to include it in a batch — must be rejected (AlreadyExists). let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); ids.push_back(id); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); assert!( result.is_err(), "batch must be rejected when an id is already scheduled" @@ -315,16 +341,19 @@ fn test_batch_all_or_nothing_on_validation_failure() { let bad_id = make_id(&test_env.env, 101); // Pre-schedule the bad id so it will collide. - client.schedule_notification(&bad_id, &creator, &ONE_HOUR); + client.schedule_notification(&bad_id, &creator, &ONE_HOUR, &make_title(&test_env.env)); let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); ids.push_back(good_id.clone()); ids.push_back(bad_id.clone()); // Will cause AlreadyExists. ttls.push_back(ONE_HOUR); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); + titles.push_back(make_title(&test_env.env)); - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); assert!(result.is_err(), "batch must fail"); // The good_id must NOT have been persisted (all-or-nothing). @@ -343,15 +372,17 @@ fn test_batch_exceeding_max_size_rejected() { // MAX_BATCH_SIZE is 50; try 51. let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); for i in 0u8..51 { let mut bytes = [0u8; 32]; bytes[0] = i; bytes[1] = 200; // Namespace to avoid collision with other tests. ids.push_back(BytesN::from_array(&test_env.env, &bytes)); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); } - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); assert!(result.is_err(), "batch exceeding max size must be rejected"); } @@ -364,16 +395,18 @@ fn test_batch_exactly_max_size_accepted() { // MAX_BATCH_SIZE is 50 — exactly 50 entries must succeed. let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); for i in 0u8..50 { let mut bytes = [0u8; 32]; bytes[0] = i; bytes[1] = 201; // Namespace to avoid collision with other tests. ids.push_back(BytesN::from_array(&test_env.env, &bytes)); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); } // Must not panic. - client.batch_schedule_notifications(&ids, &creator, &ttls); + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); // Summary event must be present. assert!( @@ -396,10 +429,12 @@ fn test_batch_blocked_when_contract_paused() { let mut ids: Vec> = Vec::new(&test_env.env); let mut ttls: Vec = Vec::new(&test_env.env); + let mut titles: Vec = Vec::new(&test_env.env); ids.push_back(make_id(&test_env.env, 110)); ttls.push_back(ONE_HOUR); + titles.push_back(make_title(&test_env.env)); - let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls); + let result = client.try_batch_schedule_notifications(&ids, &creator, &ttls, &titles); assert!( result.is_err(), "batch must be rejected while contract is paused" 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 e249a37..9a5c739 100644 --- a/contract/contracts/hello-world/src/tests/payload_validation_test.rs +++ b/contract/contracts/hello-world/src/tests/payload_validation_test.rs @@ -16,6 +16,10 @@ use soroban_sdk::{Address, BytesN, Env, String, Symbol, TryFromVal, Vec}; // ── helpers ───────────────────────────────────────────────────────────────── +fn make_title(env: &Env) -> String { + String::from_str(env, "Test Notification") +} + fn make_id(env: &Env, tag: u8) -> BytesN<32> { let mut bytes = [0u8; 32]; bytes[0] = tag; @@ -169,7 +173,7 @@ fn test_schedule_rejects_zero_ttl() { let creator = test_env.users.get(0).unwrap().clone(); let id = make_id(&test_env.env, 10); - let result = client.try_schedule_notification(&id, &creator, &0u64); + let result = client.try_schedule_notification(&id, &creator, &0u64, &make_title(&test_env.env)); assert!(result.is_err(), "zero TTL must be rejected"); } @@ -180,8 +184,8 @@ fn test_schedule_rejects_duplicate_id() { let creator = test_env.users.get(0).unwrap().clone(); let id = make_id(&test_env.env, 11); - client.schedule_notification(&id, &creator, &3_600u64); - let result = client.try_schedule_notification(&id, &creator, &3_600u64); + client.schedule_notification(&id, &creator, &3_600u64, &make_title(&test_env.env)); + let result = client.try_schedule_notification(&id, &creator, &3_600u64, &make_title(&test_env.env)); assert!( result.is_err(), "duplicate notification id must be rejected" @@ -199,7 +203,7 @@ fn test_schedule_rejects_overflow_ttl() { use soroban_sdk::testutils::Ledger; test_env.env.ledger().set_timestamp(1_000); - let result = client.try_schedule_notification(&id, &creator, &u64::MAX); + let result = client.try_schedule_notification(&id, &creator, &u64::MAX, &make_title(&test_env.env)); assert!(result.is_err(), "overflow TTL must be rejected"); } @@ -373,7 +377,7 @@ fn test_ttl_of_one_second_is_valid() { let id = make_id(&test_env.env, 20); // TTL = 1 second is the minimum valid value. - client.schedule_notification(&id, &creator, &1u64); + client.schedule_notification(&id, &creator, &1u64, &make_title(&test_env.env)); let stored = client.get_notification(&id); assert_eq!(stored.expires_at, stored.created_at + 1); } @@ -413,7 +417,7 @@ fn test_every_event_carries_category_and_priority() { // --- notification_scheduled --- let id = make_id(&test_env.env, 30); - client.schedule_notification(&id, &creator, &3_600u64); + client.schedule_notification(&id, &creator, &3_600u64, &make_title(&test_env.env)); assert!( category_of(&test_env.env, "notification_scheduled").is_some(), "notification_scheduled must carry a NotificationCategory topic" @@ -520,7 +524,7 @@ fn test_notification_events_have_notification_category() { let creator = test_env.users.get(0).unwrap().clone(); let id = make_id(&test_env.env, 40); - client.schedule_notification(&id, &creator, &3_600u64); + client.schedule_notification(&id, &creator, &3_600u64, &make_title(&test_env.env)); assert_eq!( category_of(&test_env.env, "notification_scheduled"), @@ -632,7 +636,7 @@ fn test_consumer_can_filter_by_category() { // Notification events (schedule emits audit_record_appended + notification_scheduled). let id = make_id(&test_env.env, 50); - client.schedule_notification(&id, &creator, &3_600u64); + client.schedule_notification(&id, &creator, &3_600u64, &make_title(&test_env.env)); tally(&test_env.env); // last event emitted = notification_scheduled (Notification) // Financial event. From 531bc5b4d62177c66225f2c30defba66f983a6c4 Mon Sep 17 00:00:00 2001 From: ScriptedBro Date: Sat, 27 Jun 2026 13:50:45 +0100 Subject: [PATCH 4/4] fix: resolve eslint unused vars, typescript compilation, and rust formatting issues --- .../hello-world/src/autoshare_logic.rs | 32 ++++--------------- contract/contracts/hello-world/src/lib.rs | 3 +- .../src/tests/payload_validation_test.rs | 6 ++-- dashboard/reports/wallet-integration.json | 2 +- .../src/__tests__/wallet-integration.test.tsx | 1 + dashboard/src/components/ActivityFeed.tsx | 2 +- dashboard/src/pages/EventExplorerPage.tsx | 2 +- .../__tests__/notification-flow-e2e.test.ts | 8 ++--- 8 files changed, 20 insertions(+), 36 deletions(-) diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index 7dcd09a..a099068 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,33 +1,13 @@ use crate::base::errors::Error; use crate::base::events::{ - AdminTransferred, - AuditAction, - AuditRecordAppended, - AuthorizationFailure, - AutoshareCreated, - AutoshareUpdated, - BatchNotificationsCreated, - CategoryRegistered, - ContractPaused, - ContractUnpaused, - GroupActivated, - GroupDeactivated, - NotificationCategory, - NotificationExpired, - NotificationExtended, - NotificationLimitsConfigured, - NotificationPriority, - NotificationRevoked, - NotificationScheduled, - ScheduledNotificationCancelled, - Withdrawal, + AdminTransferred, AuditAction, AuditRecordAppended, AuthorizationFailure, AutoshareCreated, + AutoshareUpdated, BatchNotificationsCreated, CategoryRegistered, ContractPaused, + ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationExpired, + NotificationExtended, NotificationLimitsConfigured, NotificationPriority, NotificationRevoked, + NotificationScheduled, ScheduledNotificationCancelled, Withdrawal, }; use crate::base::types::{ - AuditRecord, - AutoShareDetails, - GroupMember, - NotificationLimits, - PaymentHistory, + AuditRecord, AutoShareDetails, GroupMember, NotificationLimits, PaymentHistory, ScheduledNotification, }; use soroban_sdk::{contracttype, token, Address, BytesN, Env, String, Vec}; diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index be1ae02..c36d802 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -403,7 +403,8 @@ impl AutoShareContract { ttl_seconds: Vec, titles: Vec, ) { - autoshare_logic::batch_schedule_notifications(env, ids, creator, ttl_seconds, titles).unwrap(); + autoshare_logic::batch_schedule_notifications(env, ids, creator, ttl_seconds, titles) + .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 9a5c739..89a43e6 100644 --- a/contract/contracts/hello-world/src/tests/payload_validation_test.rs +++ b/contract/contracts/hello-world/src/tests/payload_validation_test.rs @@ -185,7 +185,8 @@ fn test_schedule_rejects_duplicate_id() { let id = make_id(&test_env.env, 11); client.schedule_notification(&id, &creator, &3_600u64, &make_title(&test_env.env)); - let result = client.try_schedule_notification(&id, &creator, &3_600u64, &make_title(&test_env.env)); + let result = + client.try_schedule_notification(&id, &creator, &3_600u64, &make_title(&test_env.env)); assert!( result.is_err(), "duplicate notification id must be rejected" @@ -203,7 +204,8 @@ fn test_schedule_rejects_overflow_ttl() { use soroban_sdk::testutils::Ledger; test_env.env.ledger().set_timestamp(1_000); - let result = client.try_schedule_notification(&id, &creator, &u64::MAX, &make_title(&test_env.env)); + let result = + client.try_schedule_notification(&id, &creator, &u64::MAX, &make_title(&test_env.env)); assert!(result.is_err(), "overflow TTL must be rejected"); } diff --git a/dashboard/reports/wallet-integration.json b/dashboard/reports/wallet-integration.json index 5b7362a..00cb0a5 100644 --- a/dashboard/reports/wallet-integration.json +++ b/dashboard/reports/wallet-integration.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-24T19:47:36.590Z", + "generatedAt": "2026-06-27T12:47:07.864Z", "total": 3, "passed": 3, "failed": 0, diff --git a/dashboard/src/__tests__/wallet-integration.test.tsx b/dashboard/src/__tests__/wallet-integration.test.tsx index 8726c60..18ea4fe 100644 --- a/dashboard/src/__tests__/wallet-integration.test.tsx +++ b/dashboard/src/__tests__/wallet-integration.test.tsx @@ -10,6 +10,7 @@ import * as fs from 'fs'; import * as path from 'path'; const WALLET_ID_KEY = 'notify-chain:wallet-id'; +const WALLET_ADDRESS_KEY = 'notify-chain:wallet-address'; const REPORT_PATH = path.join(process.cwd(), 'reports', 'wallet-integration.json'); type KitMock = typeof import('../test/stellarWalletsKitMock'); diff --git a/dashboard/src/components/ActivityFeed.tsx b/dashboard/src/components/ActivityFeed.tsx index 405a0d4..0ea9a01 100644 --- a/dashboard/src/components/ActivityFeed.tsx +++ b/dashboard/src/components/ActivityFeed.tsx @@ -151,7 +151,7 @@ export function ActivityFeed() { // Clear stale activity and re-fetch from page 1 whenever the connected // wallet address changes (switch or disconnect). This is the fix for issue #175. - useWalletAccountSync((_nextAddress) => { + useWalletAccountSync(() => { setEvents([]); setLiveEvents([]); setTotal(0); diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index bcf08db..39163a4 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -95,7 +95,7 @@ export function EventExplorerPage() { // Clear stale events and re-fetch whenever the connected wallet address // changes (switch or disconnect). This is the fix for issue #175. - useWalletAccountSync((_nextAddress) => { + useWalletAccountSync(() => { setEvents([]); setError(null); setPage(1); diff --git a/listener/src/__tests__/notification-flow-e2e.test.ts b/listener/src/__tests__/notification-flow-e2e.test.ts index 77c2974..ce072e1 100644 --- a/listener/src/__tests__/notification-flow-e2e.test.ts +++ b/listener/src/__tests__/notification-flow-e2e.test.ts @@ -92,13 +92,13 @@ describe('Notification flow end-to-end (e2e)', () => { await new Promise((resolve) => setTimeout(resolve, ms)); } - function futureExecuteAt(offsetMs = 50): Date { + function futureExecuteAt(offsetMs = 10000): Date { return new Date(Date.now() + offsetMs); } describe('Complete notification lifecycle', () => { it('should create, process, and deliver a notification', async () => { - const executeAt = futureExecuteAt(); + const executeAt = futureExecuteAt(50); const id = await api.scheduleNotification({ payload: { message: 'Test notification' }, @@ -127,7 +127,7 @@ describe('Notification flow end-to-end (e2e)', () => { payload: { message: 'Test notification' }, notificationType: NotificationType.DISCORD, targetRecipient: 'https://discord.com/webhook', - executeAt: futureExecuteAt(), + executeAt: futureExecuteAt(50), }); await scheduler.start(); @@ -356,7 +356,7 @@ describe('Notification flow end-to-end (e2e)', () => { }); it('should maintain audit trail through complete lifecycle', async () => { - const executeAt = futureExecuteAt(); + const executeAt = futureExecuteAt(50); const id = await api.scheduleNotification({ payload: { message: 'Audit test' },