diff --git a/quicklendx-contracts/docs/contracts/backup.md b/quicklendx-contracts/docs/contracts/backup.md index 40d4df7b..3ef57d40 100644 --- a/quicklendx-contracts/docs/contracts/backup.md +++ b/quicklendx-contracts/docs/contracts/backup.md @@ -23,6 +23,40 @@ The `restore_backup` command is highly privileged and destructive to the active 2. It permanently clears all currently tracked invoice data, wiping the existing database indexes (including business mapping, tags, metadata, and status associations). 3. It reconstitutes the original invoice state from the backup payload. +Additionally, restoring backups follows this sequence: + +Step 1 validate_backup(backup_id) + ────────────────────────── + Full integrity check performed BEFORE any mutation. Checks: + - Backup record exists and has a valid ID prefix (0xB4 0xC4). + - Backup description is non-empty and within length limits. + - Invoice payload exists in storage. + - Payload length matches backup.invoice_count. + - Every invoice in the payload has amount > 0. + + If validation fails → return error, no storage is mutated. + +Step 2 InvoiceStorage::clear_all(env) + ──────────────────────────────── + Atomically wipes every invoice and every secondary index. + After this step, instance storage contains no invoice data. + + ⚠ There is no rollback on a Soroban ledger. Reaching step 2 + means the caller has committed to discarding the current state. + Always take a backup before clearing. + +Step 3 InvoiceStorage::store_invoice(env, &invoice) per invoice + ──────────────────────────────────────────────────────── + Re-registers each invoice from the backup payload, rebuilding + all secondary indexes from scratch. The order of individual + invoices within this step does not matter. + +Step 4 Mark backup as Archived + ───────────────────────── + Sets backup.status = BackupStatus::Archived to prevent the same + backup from being restored twice. Restoring the same backup twice + without clearing in between would cause duplicate index entries. + ### Archiving Backups Backups that are older or explicitly meant for off-chain storage can be marked as archived using the `archive_backup` function. This cleanly removes them from the active list of 5 rolling backups and stops them from being accidentally overwritten by the rolling buffer. diff --git a/quicklendx-contracts/src/backup.rs b/quicklendx-contracts/src/backup.rs index 5528ac95..dfd23a39 100644 --- a/quicklendx-contracts/src/backup.rs +++ b/quicklendx-contracts/src/backup.rs @@ -8,6 +8,7 @@ const BACKUP_LIST_KEY: soroban_sdk::Symbol = symbol_short!("backups"); const BACKUP_DATA_KEY: soroban_sdk::Symbol = symbol_short!("bkup_data"); const MAX_BACKUP_DESCRIPTION_LENGTH: u32 = 128; +/// A stored snapshot of all invoices at a point in time. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Backup { @@ -18,23 +19,31 @@ pub struct Backup { pub status: BackupStatus, } +/// Lifecycle state of a [`Backup`] record. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum BackupStatus { + /// Backup is valid and available for restore. Active, + /// Backup has been superseded and should not be restored. Archived, + /// Backup data failed integrity checks and must not be restored. Corrupted, } -/// Backup retention policy configuration +/// Backup retention policy configuration. +/// +/// Controls how many backups are kept and for how long. When +/// `auto_cleanup_enabled` is `true`, `cleanup_old_backups` enforces both +/// `max_backups` and `max_age_seconds` on every invocation. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct BackupRetentionPolicy { - /// Maximum number of backups to keep (0 = unlimited) + /// Maximum number of backups to keep (0 = unlimited). pub max_backups: u32, - /// Maximum age of backups in seconds (0 = unlimited) + /// Maximum age of backups in seconds (0 = unlimited). pub max_age_seconds: u64, - /// Whether automatic cleanup is enabled + /// Whether automatic cleanup is enabled. pub auto_cleanup_enabled: bool, } @@ -48,10 +57,15 @@ impl Default for BackupRetentionPolicy { } } +/// Low-level backup storage operations. +/// +/// All public functions are thin wrappers around Soroban instance storage. +/// Higher-level orchestration (backup-then-clear, validate-then-restore) lives +/// in [`BackupStorage::restore_from_backup`] which is the **only** safe entry +/// point for restoring data. pub struct BackupStorage; impl BackupStorage { - /// @notice Validate backup metadata before persisting it. fn validate_backup_metadata( backup: &Backup, invoices: Option<&Vec>, @@ -60,7 +74,8 @@ impl BackupStorage { return Err(QuickLendXError::StorageError); } - if backup.description.len() == 0 || backup.description.len() > MAX_BACKUP_DESCRIPTION_LENGTH + if backup.description.len() == 0 + || backup.description.len() > MAX_BACKUP_DESCRIPTION_LENGTH { return Err(QuickLendXError::InvalidDescription); } @@ -74,13 +89,12 @@ impl BackupStorage { Ok(()) } - /// @notice Validate the backup identifier prefix format. pub fn is_valid_backup_id(backup_id: &BytesN<32>) -> bool { let bytes = backup_id.to_array(); bytes[0] == 0xB4 && bytes[1] == 0xC4 } - /// Get the backup retention policy + /// Get the backup retention policy. pub fn get_retention_policy(env: &Env) -> BackupRetentionPolicy { env.storage() .instance() @@ -88,12 +102,14 @@ impl BackupStorage { .unwrap_or_else(|| BackupRetentionPolicy::default()) } - /// Set the backup retention policy (admin only) + /// Set the backup retention policy (admin only — caller must enforce auth). pub fn set_retention_policy(env: &Env, policy: &BackupRetentionPolicy) { env.storage().instance().set(&RETENTION_POLICY_KEY, policy); } - /// Generate a unique backup ID + /// Generate a unique backup ID. + /// + /// Format: `0xB4 0xC4 | timestamp(8B) | counter(8B) | mix(14B)`. pub fn generate_backup_id(env: &Env) -> BytesN<32> { let timestamp = env.ledger().timestamp(); let counter: u64 = env @@ -107,14 +123,10 @@ impl BackupStorage { .set(&BACKUP_COUNTER_KEY, &next_counter); let mut id_bytes = [0u8; 32]; - // Add backup prefix - id_bytes[0] = 0xB4; // 'B' for Backup - id_bytes[1] = 0xC4; // 'C' for baCkup - // Embed timestamp + id_bytes[0] = 0xB4; + id_bytes[1] = 0xC4; id_bytes[2..10].copy_from_slice(×tamp.to_be_bytes()); - // Embed counter id_bytes[10..18].copy_from_slice(&next_counter.to_be_bytes()); - // Fill remaining bytes (overflow-safe) let mix = timestamp .saturating_add(next_counter) .saturating_add(0xB4C4); @@ -125,7 +137,11 @@ impl BackupStorage { BytesN::from_array(env, &id_bytes) } - /// Store a backup record + + /// Persist a backup record (metadata only). + /// + /// Returns [`QuickLendXError::OperationNotAllowed`] if a backup with the + /// same ID already exists, preventing accidental overwrites. pub fn store_backup( env: &Env, backup: &Backup, @@ -141,19 +157,19 @@ impl BackupStorage { Ok(()) } - /// Get a backup by ID + /// Retrieve a backup record by ID. pub fn get_backup(env: &Env, backup_id: &BytesN<32>) -> Option { env.storage().instance().get(backup_id) } - /// Update a backup record + /// Update an existing backup record (e.g. to mark it `Archived`). pub fn update_backup(env: &Env, backup: &Backup) -> Result<(), QuickLendXError> { Self::validate_backup_metadata(backup, None)?; env.storage().instance().set(&backup.backup_id, backup); Ok(()) } - /// Get all backup IDs + /// Get all backup IDs in the global backup list. pub fn get_all_backups(env: &Env) -> Vec> { env.storage() .instance() @@ -161,7 +177,7 @@ impl BackupStorage { .unwrap_or_else(|| Vec::new(env)) } - /// Add backup to the list of all backups + /// Append a backup ID to the global backup list (deduplication guard included). pub fn add_to_backup_list(env: &Env, backup_id: &BytesN<32>) { let mut backups = Self::get_all_backups(env); for existing in backups.iter() { @@ -173,7 +189,7 @@ impl BackupStorage { env.storage().instance().set(&BACKUP_LIST_KEY, &backups); } - /// Remove backup from the list (when archived or corrupted) + /// Remove a backup ID from the global backup list. pub fn remove_from_backup_list(env: &Env, backup_id: &BytesN<32>) { let backups = Self::get_all_backups(env); let mut new_backups = Vec::new(env); @@ -185,19 +201,19 @@ impl BackupStorage { env.storage().instance().set(&BACKUP_LIST_KEY, &new_backups); } - /// Store invoice data for a backup + /// Store the invoice payload for a backup. pub fn store_backup_data(env: &Env, backup_id: &BytesN<32>, invoices: &Vec) { let key = (BACKUP_DATA_KEY, backup_id.clone()); env.storage().instance().set(&key, invoices); } - /// Get invoice data from a backup + /// Retrieve the invoice payload for a backup. pub fn get_backup_data(env: &Env, backup_id: &BytesN<32>) -> Option> { let key = (BACKUP_DATA_KEY, backup_id.clone()); env.storage().instance().get(&key) } - /// @notice Delete a backup record and its stored data. + /// Delete a backup record and its stored invoice payload. pub fn purge_backup(env: &Env, backup_id: &BytesN<32>) { Self::remove_from_backup_list(env, backup_id); env.storage().instance().remove(backup_id); @@ -205,22 +221,30 @@ impl BackupStorage { env.storage().instance().remove(&data_key); } - /// Validate backup data integrity + + /// Validate backup data integrity. + /// + /// Checks that: + /// 1. The backup record exists and has a valid ID prefix. + /// 2. The invoice payload exists. + /// 3. The payload length matches `backup.invoice_count`. + /// 4. Every invoice in the payload has a positive `amount`. pub fn validate_backup(env: &Env, backup_id: &BytesN<32>) -> Result<(), QuickLendXError> { - let backup = Self::get_backup(env, backup_id).ok_or(QuickLendXError::StorageKeyNotFound)?; + let backup = + Self::get_backup(env, backup_id).ok_or(QuickLendXError::StorageKeyNotFound)?; + + // Validate metadata alone first (cheap). Self::validate_backup_metadata(&backup, None)?; + // Fetch the payload and validate together with the count. let data = Self::get_backup_data(env, backup_id).ok_or(QuickLendXError::StorageKeyNotFound)?; - Self::validate_backup_metadata(&backup, Some(&data))?; - - // Check if count matches if data.len() as u32 != backup.invoice_count { return Err(QuickLendXError::StorageError); } - // Check each invoice has valid data + // Validate each invoice record in the payload. for invoice in data.iter() { if invoice.amount <= 0 { return Err(QuickLendXError::StorageError); @@ -230,11 +254,100 @@ impl BackupStorage { Ok(()) } - /// Clean up old backups based on retention policy + + /// Restore all invoices from a backup in a safe, validated sequence. + /// + /// # Restore ordering + /// + /// The ordering of operations is critical to prevent orphan indexes and + /// partial-state corruption: + /// + /// ```text + /// Step 1 validate_backup() + /// ───────────────── + /// Full integrity check BEFORE any mutation. If the backup is + /// corrupt or the invoice_count mismatches, we abort here and + /// leave existing storage completely untouched. + /// + /// Step 2 InvoiceStorage::clear_all() + /// ──────────────────────────── + /// Atomically removes every invoice record, status bucket, + /// category index, tag index, business index, and metadata index. + /// After this step storage is empty. There is no rollback + /// mechanism on a Soroban ledger; reaching this step means the + /// caller has accepted that the current state will be discarded. + /// + /// Step 3 InvoiceStorage::store_invoice() per invoice + /// ──────────────────────────────────────────── + /// Re-registers each invoice from the backup payload, rebuilding + /// all secondary indexes from scratch. The write order within + /// this step does not matter because `store_invoice` is + /// self-contained. + /// + /// Step 4 Mark the backup as Archived + /// ──────────────────────────── + /// Prevents the same backup from being restored twice, which + /// could cause duplicate invoice registrations if the store is + /// not cleared between restores. + /// ``` + /// + /// # Errors + /// + /// Returns an error *only* in step 1. Steps 2–4 are infallible on a + /// well-formed Soroban environment; panics in those steps indicate a + /// platform bug, not a contract bug. + /// + /// # Security + /// + /// - The caller **must** enforce admin authentication before invoking this + /// function. The contract entry point is responsible for `require_auth`. + /// - Validate → clear → restore is the only safe ordering. Clearing + /// before validating would leave the contract in an empty state if the + /// backup turns out to be corrupt. + /// - Restoring without clearing first would overlay backup data on stale + /// indexes, causing ghost entries in status/category/tag buckets for + /// any invoices that existed before the restore. + pub fn restore_from_backup( + env: &Env, + backup_id: &BytesN<32>, + ) -> Result { + // Step 1: validate before mutating anything + Self::validate_backup(env, backup_id)?; + + // Fetch the validated payload. + let data = Self::get_backup_data(env, backup_id) + .ok_or(QuickLendXError::StorageKeyNotFound)?; + + let restored_count = data.len(); + + // Step 2: atomically clear all existing invoice state + crate::invoice::InvoiceStorage::clear_all(env); + + // Step 3: re-register every invoice, rebuilding all indexes + for invoice in data.iter() { + crate::invoice::InvoiceStorage::store_invoice(env, &invoice); + } + + // Step 4: mark the backup as archived to prevent re-use + if let Some(mut backup) = Self::get_backup(env, backup_id) { + backup.status = BackupStatus::Archived; + // Ignore the result — the restore itself has already succeeded. + let _ = Self::update_backup(env, &backup); + } + + Ok(restored_count) + } + + /// Clean up old backups based on the retention policy. + /// + /// Removes backups that exceed `max_age_seconds`, then removes the oldest + /// backups until the count is within `max_backups`. Only `Active` backups + /// are considered; `Archived` and `Corrupted` backups are left untouched. + /// + /// Returns the number of backups removed. pub fn cleanup_old_backups(env: &Env) -> Result { let policy = Self::get_retention_policy(env); - // If auto cleanup is disabled, do nothing if !policy.auto_cleanup_enabled { return Ok(0); } @@ -243,22 +356,23 @@ impl BackupStorage { let current_time = env.ledger().timestamp(); let mut removed_count = 0u32; - // Create a vector of tuples (backup_id, timestamp) for sorting + // Build (backup_id, timestamp) pairs for active backups only. let mut backup_timestamps = Vec::new(env); for backup_id in backups.iter() { if let Some(backup) = Self::get_backup(env, &backup_id) { - // Only consider active backups for cleanup if backup.status == BackupStatus::Active { backup_timestamps.push_back((backup_id, backup.timestamp)); } } } - // Sort by timestamp (oldest first) using bubble sort + // Bubble sort: oldest first. let len = backup_timestamps.len(); for i in 0..len { for j in 0..len - i - 1 { - if backup_timestamps.get(j).unwrap().1 > backup_timestamps.get(j + 1).unwrap().1 { + if backup_timestamps.get(j).unwrap().1 + > backup_timestamps.get(j + 1).unwrap().1 + { let temp = backup_timestamps.get(j).unwrap().clone(); backup_timestamps.set(j, backup_timestamps.get(j + 1).unwrap().clone()); backup_timestamps.set(j + 1, temp); @@ -266,7 +380,7 @@ impl BackupStorage { } } - // First, remove backups that exceed max age (if configured) + // Remove backups that exceed max age. if policy.max_age_seconds > 0 { let mut i = 0; while i < backup_timestamps.len() { @@ -283,7 +397,7 @@ impl BackupStorage { } } - // Then, remove oldest backups if we exceed max_backups (if configured) + // Remove oldest backups until within the max_backups limit. if policy.max_backups > 0 { while backup_timestamps.len() > policy.max_backups { if let Some((oldest_id, _)) = backup_timestamps.first() { @@ -297,7 +411,10 @@ impl BackupStorage { Ok(removed_count) } - /// Retrieve all invoices from storage across all possible statuses + + /// Retrieve all invoices from storage across all possible statuses. + /// + /// Used when creating a new backup to snapshot the full current state. pub fn get_all_invoices(env: &Env) -> Vec { let mut all_invoices = Vec::new(env); let all_statuses = [ @@ -320,4 +437,4 @@ impl BackupStorage { } all_invoices } -} +} \ No newline at end of file diff --git a/quicklendx-contracts/src/test_backup.rs b/quicklendx-contracts/src/test_backup.rs index 4edaa381..781ed472 100644 --- a/quicklendx-contracts/src/test_backup.rs +++ b/quicklendx-contracts/src/test_backup.rs @@ -13,6 +13,7 @@ use soroban_sdk::{ fn setup() -> (Env, QuickLendXContractClient<'static>, Address) { let env = Env::default(); env.mock_all_auths(); + env.ledger().set_timestamp(1_000_000); let contract_id = env.register(QuickLendXContract, ()); let client = QuickLendXContractClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -41,6 +42,74 @@ fn create_invoice( ) } + /// Create a minimal Invoice suitable for backup tests. + fn make_invoice(env: &Env, idx: u32) -> Invoice { + use soroban_sdk::{vec, Address, BytesN}; + use crate::invoice::{Dispute, DisputeStatus}; + + let mut id_bytes = [0u8; 32]; + id_bytes[28..32].copy_from_slice(&idx.to_be_bytes()); + let id = BytesN::from_array(env, &id_bytes); + + Invoice { + id, + business: Address::generate(env), + amount: 500_i128 * (idx as i128 + 1), + currency: Address::generate(env), + due_date: 9_999_999_999, + status: InvoiceStatus::Pending, + created_at: env.ledger().timestamp(), + description: String::from_str(env, "backup test"), + metadata_customer_name: None, + metadata_customer_address: None, + metadata_tax_id: None, + metadata_notes: None, + metadata_line_items: soroban_sdk::Vec::new(env), + category: InvoiceCategory::Services, + tags: soroban_sdk::Vec::new(env), + funded_amount: 0, + funded_at: None, + investor: None, + settled_at: None, + average_rating: None, + total_ratings: 0, + ratings: vec![env], + dispute_status: DisputeStatus::None, + dispute: Dispute { + created_by: Address::generate(env), + created_at: 0, + reason: String::from_str(env, ""), + evidence: String::from_str(env, ""), + resolution: String::from_str(env, ""), + resolved_by: Address::generate(env), + resolved_at: 0, + }, + total_paid: 0, + payment_history: vec![env], + } + } + + /// Persist a complete, valid backup (metadata + data) and return its ID. + fn create_valid_backup(env: &Env, invoices: Vec) -> soroban_sdk::BytesN<32> { + let backup_id = BackupStorage::generate_backup_id(env); + let count = invoices.len(); + + let backup = Backup { + backup_id: backup_id.clone(), + timestamp: env.ledger().timestamp(), + description: String::from_str(env, "test backup"), + invoice_count: count, + status: BackupStatus::Active, + }; + + BackupStorage::store_backup(env, &backup, Some(&invoices)).unwrap(); + BackupStorage::store_backup_data(env, &backup_id, &invoices); + BackupStorage::add_to_backup_list(env, &backup_id); + + backup_id + } + + #[test] fn test_create_backup_requires_admin_and_stores_valid_metadata() { let (env, client, admin) = setup(); @@ -212,3 +281,52 @@ fn test_update_backup_rejects_invalid_description() { let result = BackupStorage::update_backup(&env, &invalid); assert_eq!(result, Err(QuickLendXError::InvalidDescription)); } + + + #[test] + fn validate_backup_fails_when_record_missing() { + let env = setup_env(); + let id = BackupStorage::generate_backup_id(&env); + // No record stored — must fail. + assert!(BackupStorage::validate_backup(&env, &id).is_err()); + } + + #[test] + fn validate_backup_fails_when_data_missing() { + let env = setup_env(); + let backup_id = BackupStorage::generate_backup_id(&env); + let backup = Backup { + backup_id: backup_id.clone(), + timestamp: env.ledger().timestamp(), + description: String::from_str(&env, "no data"), + invoice_count: 1, + status: BackupStatus::Active, + }; + BackupStorage::store_backup(&env, &backup, None).unwrap(); + // Data blob never stored. + assert!(BackupStorage::validate_backup(&env, &backup_id).is_err()); + } + + #[test] + fn validate_backup_fails_on_count_mismatch() { + let env = setup_env(); + let backup_id = BackupStorage::generate_backup_id(&env); + let invoices: Vec = { + let mut v = Vec::new(&env); + v.push_back(make_invoice(&env, 0)); + v + }; + + // Claim count = 2, but only 1 invoice in data. + let backup = Backup { + backup_id: backup_id.clone(), + timestamp: env.ledger().timestamp(), + description: String::from_str(&env, "mismatch"), + invoice_count: 2, + status: BackupStatus::Active, + }; + env.storage().instance().set(&backup_id, &backup); + BackupStorage::store_backup_data(&env, &backup_id, &invoices); + + assert!(BackupStorage::validate_backup(&env, &backup_id).is_err()); + } diff --git a/quicklendx-contracts/src/test_storage.rs b/quicklendx-contracts/src/test_storage.rs index 0b23f7a0..2c4c55e6 100644 --- a/quicklendx-contracts/src/test_storage.rs +++ b/quicklendx-contracts/src/test_storage.rs @@ -13,7 +13,7 @@ use crate::bid::{Bid, BidStatus}; use crate::investment::{Investment, InvestmentStatus}; use crate::invoice::{ Dispute, Invoice, InvoiceCategory, InvoiceMetadata, InvoiceStatus, LineItemRecord, - PaymentRecord, + PaymentRecord, InoiceStorage, TOTAL_INCOICE_COUNT_KEY, }; use crate::profits::{PlatformFee, PlatformFeeConfig}; use crate::storage::{ @@ -21,6 +21,62 @@ use crate::storage::{ }; use crate::QuickLendXContract; +/// Create a minimal valid `Invoice` stub directly (bypasses `Invoice::new` + /// which requires the full contract environment with audit logging). + fn make_invoice(env: &Env, idx: u32) -> Invoice { + use soroban_sdk::{vec, Address, BytesN, String}; + use crate::invoice::{ + Dispute, DisputeStatus, InvoiceCategory, InvoiceStatus, LineItemRecord, + }; + + let mut id_bytes = [0u8; 32]; + id_bytes[28..32].copy_from_slice(&idx.to_be_bytes()); + let id = BytesN::from_array(env, &id_bytes); + + Invoice { + id, + business: Address::generate(env), + amount: 1_000_i128 * (idx as i128 + 1), + currency: Address::generate(env), + due_date: 9_999_999_999, + status: InvoiceStatus::Pending, + created_at: env.ledger().timestamp(), + description: String::from_str(env, "test invoice"), + metadata_customer_name: None, + metadata_customer_address: None, + metadata_tax_id: None, + metadata_notes: None, + metadata_line_items: soroban_sdk::Vec::new(env), + category: InvoiceCategory::Services, + tags: soroban_sdk::Vec::new(env), + funded_amount: 0, + funded_at: None, + investor: None, + settled_at: None, + average_rating: None, + total_ratings: 0, + ratings: vec![env], + dispute_status: DisputeStatus::None, + dispute: Dispute { + created_by: Address::generate(env), + created_at: 0, + reason: String::from_str(env, ""), + evidence: String::from_str(env, ""), + resolution: String::from_str(env, ""), + resolved_by: Address::generate(env), + resolved_at: 0, + }, + total_paid: 0, + payment_history: vec![env], + } + } + + fn setup_env() -> Env { + let env = Env::default(); + env.ledger().set_timestamp(1_000_000); + env + } + fn with_registered_contract(env: &Env, f: F) { let contract_id = env.register(QuickLendXContract, ()); env.as_contract(&contract_id, f); @@ -1028,3 +1084,43 @@ fn test_storage_retrieval_consistency() { assert_eq!(invoice, retrieved1, "Retrieved should match stored"); }); } + + #[test] + fn clear_all_removes_invoice_records() { + let env = setup_env(); + let inv = make_invoice(&env, 0); + let id = inv.id.clone(); + InvoiceStorage::store_invoice(&env, &inv); + + assert!(InvoiceStorage::get_invoice(&env, &id).is_some()); + InvoiceStorage::clear_all(&env); + assert!(InvoiceStorage::get_invoice(&env, &id).is_none()); + } + + #[test] + fn clear_all_empties_status_buckets() { + let env = setup_env(); + let inv = make_invoice(&env, 1); + InvoiceStorage::store_invoice(&env, &inv); + + InvoiceStorage::clear_all(&env); + + let pending = InvoiceStorage::get_invoices_by_status(&env, &InvoiceStatus::Pending); + assert_eq!(pending.len(), 0, "pending bucket must be empty after clear_all"); + } + + #[test] + fn clear_all_empties_category_index() { + let env = setup_env(); + let inv = make_invoice(&env, 2); + InvoiceStorage::store_invoice(&env, &inv); + + InvoiceStorage::clear_all(&env); + + // After clear the category index for Services must be empty. + let key = (soroban_sdk::symbol_short!("cat_idx"), InvoiceCategory::Services); + let bucket: Option>> = + env.storage().instance().get(&key); + let len = bucket.map(|v| v.len()).unwrap_or(0); + assert_eq!(len, 0, "category index must have no orphans after clear_all"); + }