From 3a6899d147f58c3896a56a85748c88f55e8f56fb Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sun, 31 May 2026 17:53:28 +0100 Subject: [PATCH] test(contract): add snapshot/restore consistency tests Add three consistency tests to the creator-earnings contract: - test_snapshot_restore_consistency: deposit, withdraw, re-deposit restores the internal balance to the recorded mid-state snapshot - test_sequential_deposits_accumulate_consistently: repeated deposits sum correctly and a full withdrawal returns balance to zero - test_multi_creator_balances_independent: operations on one creator do not affect another creator's balance snapshot Also fixes a pre-existing compile error in content-likes: missing `contracttype` import and invalid `panic_with_error!` string literal (replaced with `Error::AlreadyInitialized`). Applies cargo fmt to pre-existing formatting violations across content-access, content-likes, subscription, test-consumer, and treasury. Closes MyFanss/MyFans#944 --- contract/contracts/content-access/src/lib.rs | 21 ++++- .../content-access/src/tests/event_tests.rs | 4 +- .../src/tests/init_admin_tests.rs | 29 +++++-- .../src/tests/unauthorized_tests.rs | 12 ++- contract/contracts/content-likes/src/lib.rs | 13 ++- .../tests/contract_integration.rs | 5 +- .../contracts/creator-earnings/src/test.rs | 82 +++++++++++++++++++ contract/contracts/subscription/src/test.rs | 10 ++- contract/contracts/test-consumer/src/lib.rs | 24 ++++-- contract/contracts/treasury/src/lib.rs | 6 +- contract/contracts/treasury/src/test.rs | 13 ++- .../treasury/src/tests/error_tests.rs | 10 ++- 12 files changed, 186 insertions(+), 43 deletions(-) diff --git a/contract/contracts/content-access/src/lib.rs b/contract/contracts/content-access/src/lib.rs index 492a7508..8c5152f1 100644 --- a/contract/contracts/content-access/src/lib.rs +++ b/contract/contracts/content-access/src/lib.rs @@ -127,7 +127,10 @@ impl ContentAccess { // Check if already unlocked (idempotent) – but re-check expiry. let access_key = DataKey::Access(buyer.clone(), creator.clone(), content_id); - if let Some(existing) = env.storage().instance().get::(&access_key) + if let Some(existing) = env + .storage() + .instance() + .get::(&access_key) { // If the existing purchase is still valid, treat as no-op. if existing.expiry > current_seq { @@ -166,7 +169,11 @@ impl ContentAccess { /// Check if buyer has valid (non-expired) access to content. pub fn has_access(env: Env, buyer: Address, creator: Address, content_id: u64) -> bool { let access_key = DataKey::Access(buyer, creator, content_id); - if let Some(purchase) = env.storage().instance().get::(&access_key) { + if let Some(purchase) = env + .storage() + .instance() + .get::(&access_key) + { let current_seq: u64 = env.ledger().sequence() as u64; purchase.expiry > current_seq } else { @@ -853,7 +860,10 @@ mod test { assert!(client.has_access(&buyer, &creator, &1)); // verify_access should not panic (we test this by not expecting an error) let verify_result = client.try_verify_access(&buyer, &creator, &1); - assert!(verify_result.is_ok(), "verify_access should succeed when has_access is true"); + assert!( + verify_result.is_ok(), + "verify_access should succeed when has_access is true" + ); } /// Invariant: If verify_access succeeds, has_access should return true. @@ -879,7 +889,10 @@ mod test { client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); assert!(client.has_access(&buyer, &creator, &1)); let verify_result = client.try_verify_access(&buyer, &creator, &1); - assert!(verify_result.is_ok(), "verify_access should succeed after unlock"); + assert!( + verify_result.is_ok(), + "verify_access should succeed after unlock" + ); } /// Invariant: Price set by creator should be retrievable. diff --git a/contract/contracts/content-access/src/tests/event_tests.rs b/contract/contracts/content-access/src/tests/event_tests.rs index b05b5725..72e9b0a5 100644 --- a/contract/contracts/content-access/src/tests/event_tests.rs +++ b/contract/contracts/content-access/src/tests/event_tests.rs @@ -1,7 +1,5 @@ use crate::{ - events::{ - AdminTransferredEvent, ContentPriceSetEvent, MaxPriceClearedEvent, MaxPriceSetEvent, - }, + events::{AdminTransferredEvent, ContentPriceSetEvent, MaxPriceClearedEvent, MaxPriceSetEvent}, ContentAccess, ContentAccessClient, }; use soroban_sdk::{ diff --git a/contract/contracts/content-access/src/tests/init_admin_tests.rs b/contract/contracts/content-access/src/tests/init_admin_tests.rs index 91a1cd85..fd8e78a5 100644 --- a/contract/contracts/content-access/src/tests/init_admin_tests.rs +++ b/contract/contracts/content-access/src/tests/init_admin_tests.rs @@ -13,9 +13,7 @@ use crate::{ContentAccess, ContentAccessClient, Error}; use soroban_sdk::{ - testutils::Address as _, - xdr::SorobanAuthorizationEntry, - Address, Env, Error as SorobanError, + testutils::Address as _, xdr::SorobanAuthorizationEntry, Address, Env, Error as SorobanError, }; const EMPTY_AUTHS: &[SorobanAuthorizationEntry] = &[]; @@ -62,7 +60,11 @@ fn initialize_stores_admin() { client.initialize(&admin, &token_id); - assert_eq!(client.admin(), admin, "admin() must return the initialized admin"); + assert_eq!( + client.admin(), + admin, + "admin() must return the initialized admin" + ); } /// initialize stores the token address; set_content_price succeeds after init. @@ -136,7 +138,10 @@ fn admin_view_panics_when_uninitialized() { let client = ContentAccessClient::new(&env, &contract_id); let result = client.try_admin(); - assert!(result.is_err(), "admin() must fail on uninitialized contract"); + assert!( + result.is_err(), + "admin() must fail on uninitialized contract" + ); } // ── set_admin ───────────────────────────────────────────────────────────────── @@ -152,7 +157,8 @@ fn set_admin_transfers_admin_role() { client.set_admin(&new_admin); assert_eq!( - client.admin(), new_admin, + client.admin(), + new_admin, "admin() must return new admin after set_admin" ); } @@ -246,7 +252,11 @@ fn set_max_price_zero_clears_cap() { assert_eq!(client.get_max_price(), Some(500_000)); client.set_max_price(&0); - assert_eq!(client.get_max_price(), None, "cap must be removed after set_max_price(0)"); + assert_eq!( + client.get_max_price(), + None, + "cap must be removed after set_max_price(0)" + ); } /// Non-admin cannot call set_max_price. @@ -264,7 +274,10 @@ fn set_max_price_rejected_for_non_admin() { env.set_auths(EMPTY_AUTHS); let result = client.try_set_max_price(&500_000); - assert!(result.is_err(), "set_max_price must fail without admin auth"); + assert!( + result.is_err(), + "set_max_price must fail without admin auth" + ); } /// Prices above max_price are rejected when cap is configured. diff --git a/contract/contracts/content-access/src/tests/unauthorized_tests.rs b/contract/contracts/content-access/src/tests/unauthorized_tests.rs index 3e2e3476..e147fd98 100644 --- a/contract/contracts/content-access/src/tests/unauthorized_tests.rs +++ b/contract/contracts/content-access/src/tests/unauthorized_tests.rs @@ -30,7 +30,13 @@ fn setup(env: &Env) -> (ContentAccessClient<'_>, Address, Address) { (client, admin, token_address) } -fn mock_rogue_auth(env: &Env, rogue: &Address, contract: &Address, fn_name: &'static str, args: soroban_sdk::Vec) { +fn mock_rogue_auth( + env: &Env, + rogue: &Address, + contract: &Address, + fn_name: &'static str, + args: soroban_sdk::Vec, +) { env.mock_auths(&[MockAuth { address: rogue, invoke: &MockAuthInvoke { @@ -163,7 +169,9 @@ fn initialize_reverts_if_already_initialized() { let second_admin = Address::generate(&env); env.mock_all_auths(); - assert!(client.try_initialize(&second_admin, &token_address).is_err()); + assert!(client + .try_initialize(&second_admin, &token_address) + .is_err()); } #[test] diff --git a/contract/contracts/content-likes/src/lib.rs b/contract/contracts/content-likes/src/lib.rs index b9ca3d89..b273e853 100644 --- a/contract/contracts/content-likes/src/lib.rs +++ b/contract/contracts/content-likes/src/lib.rs @@ -1,6 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, panic_with_error, Address, Env, Map, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, Map, + Symbol, Vec, }; mod events; @@ -24,11 +25,14 @@ pub enum DataKey { /// | Code | Variant | /// |------|---------| /// | 1 | `NotLiked` | +/// | 2 | `AlreadyInitialized` | #[contracterror] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Error { /// Code 1 – user has not liked this content; `unlike` was called without a prior `like`. NotLiked = 1, + /// Code 2 – contract was already initialized. + AlreadyInitialized = 2, } #[contract] @@ -40,7 +44,7 @@ impl ContentLikes { pub fn initialize(env: Env, admin: Address) { admin.require_auth(); if env.storage().instance().has(&DataKey::Admin) { - panic_with_error!(&env, "already initialized"); + panic_with_error!(&env, Error::AlreadyInitialized); } env.storage().instance().set(&DataKey::Admin, &admin); } @@ -584,7 +588,10 @@ mod test { // Verify events were published let events = env.events().all(); - assert!(events.len() >= 2, "Expected at least 2 events (like and unlike)"); + assert!( + events.len() >= 2, + "Expected at least 2 events (like and unlike)" + ); } #[test] diff --git a/contract/contracts/content-likes/tests/contract_integration.rs b/contract/contracts/content-likes/tests/contract_integration.rs index 58362594..94da9686 100644 --- a/contract/contracts/content-likes/tests/contract_integration.rs +++ b/contract/contracts/content-likes/tests/contract_integration.rs @@ -63,7 +63,10 @@ fn test_error_unlike_without_like() { // Try to unlike without ever liking — should fail with NotLiked error (code 1) let result = client.try_unlike(&user, &content_id); - assert!(result.is_err(), "Expected unlike without prior like to fail with NotLiked error"); + assert!( + result.is_err(), + "Expected unlike without prior like to fail with NotLiked error" + ); } /// Test multiple users liking the same content. diff --git a/contract/contracts/creator-earnings/src/test.rs b/contract/contracts/creator-earnings/src/test.rs index 0f82d971..35975847 100644 --- a/contract/contracts/creator-earnings/src/test.rs +++ b/contract/contracts/creator-earnings/src/test.rs @@ -240,3 +240,85 @@ fn withdraw_failed_emits_no_event() { assert_eq!(client.balance(&creator), 500); assert!(env.events().all().len() >= events_before); } + +// -------- Snapshot / restore consistency tests for issue #944 -------- + +/// Verify that depositing, withdrawing, then re-depositing the same amount +/// restores the internal balance to the intermediate snapshot. +#[test] +fn test_snapshot_restore_consistency() { + let env = Env::default(); + let (_admin, creator, depositor, client, _, token_admin_client) = setup(&env); + + // Initial snapshot: balance is zero + assert_eq!(client.balance(&creator), 0); + + // Deposit to a known state + client.deposit(&depositor, &creator, &400); + let mid_snapshot = client.balance(&creator); + assert_eq!(mid_snapshot, 400); + + // Partial withdrawal moves balance below snapshot + client.withdraw(&creator, &150); + assert_eq!(client.balance(&creator), 250); + + // Re-mint so depositor can fund the restore deposit + token_admin_client.mint(&depositor, &150); + + // Re-deposit the withdrawn amount to restore to mid-snapshot + client.deposit(&depositor, &creator, &150); + assert_eq!(client.balance(&creator), mid_snapshot); +} + +/// Verify that sequential deposits accumulate correctly and a full withdrawal +/// returns the balance to zero. +#[test] +fn test_sequential_deposits_accumulate_consistently() { + let env = Env::default(); + let (_admin, creator, depositor, client, _, token_admin_client) = setup(&env); + + client.deposit(&depositor, &creator, &100); + assert_eq!(client.balance(&creator), 100); + + token_admin_client.mint(&depositor, &200); + client.deposit(&depositor, &creator, &200); + assert_eq!(client.balance(&creator), 300); + + token_admin_client.mint(&depositor, &300); + client.deposit(&depositor, &creator, &300); + assert_eq!(client.balance(&creator), 600); + + // Full withdrawal restores balance to zero + client.withdraw(&creator, &600); + assert_eq!(client.balance(&creator), 0); +} + +/// Verify that balances across multiple creators remain independent; a deposit +/// or withdrawal for one creator does not affect the snapshot of another. +#[test] +fn test_multi_creator_balances_independent() { + let env = Env::default(); + let (_admin, _creator, depositor, client, _, _) = setup(&env); + + let creator_a = Address::generate(&env); + let creator_b = Address::generate(&env); + + // Snapshot: both creators start at 0 + assert_eq!(client.balance(&creator_a), 0); + assert_eq!(client.balance(&creator_b), 0); + + // Deposit to creator_a only (depositor has 1_000 from setup) + client.deposit(&depositor, &creator_a, &300); + assert_eq!(client.balance(&creator_a), 300); + assert_eq!(client.balance(&creator_b), 0); + + // Deposit to creator_b + client.deposit(&depositor, &creator_b, &200); + assert_eq!(client.balance(&creator_a), 300); + assert_eq!(client.balance(&creator_b), 200); + + // Withdrawal from creator_a must not affect creator_b + client.withdraw(&creator_a, &100); + assert_eq!(client.balance(&creator_a), 200); + assert_eq!(client.balance(&creator_b), 200); +} diff --git a/contract/contracts/subscription/src/test.rs b/contract/contracts/subscription/src/test.rs index 32a5a2b5..285f60c7 100644 --- a/contract/contracts/subscription/src/test.rs +++ b/contract/contracts/subscription/src/test.rs @@ -1183,7 +1183,10 @@ fn test_pause_non_admin_rejected() { let result = client.try_pause(); assert!(result.is_err(), "non-admin must not pause the contract"); - assert!(!client.is_paused(), "contract must remain unpaused after unauthorized pause attempt"); + assert!( + !client.is_paused(), + "contract must remain unpaused after unauthorized pause attempt" + ); } #[test] @@ -1198,7 +1201,10 @@ fn test_unpause_non_admin_rejected() { env.set_auths(&[]); let result = client.try_unpause(); assert!(result.is_err(), "non-admin must not unpause the contract"); - assert!(client.is_paused(), "contract must remain paused after unauthorized unpause attempt"); + assert!( + client.is_paused(), + "contract must remain paused after unauthorized unpause attempt" + ); } // ── set_fee_recipient (admin fee recipient rotation) ───────────────────────── diff --git a/contract/contracts/test-consumer/src/lib.rs b/contract/contracts/test-consumer/src/lib.rs index 6f921b83..52169b77 100644 --- a/contract/contracts/test-consumer/src/lib.rs +++ b/contract/contracts/test-consumer/src/lib.rs @@ -217,10 +217,7 @@ mod test { mod creator_deposits_integration { use creator_deposits::{CreatorDeposits, CreatorDepositsClient, Error as DepositError}; use myfans_token::{MyFansToken, MyFansTokenClient}; - use soroban_sdk::{ - testutils::Address as _, - Address, Env, String, - }; + use soroban_sdk::{testutils::Address as _, Address, Env, String}; fn deploy_token(env: &Env) -> (MyFansTokenClient<'_>, Address) { let admin = Address::generate(env); @@ -640,7 +637,10 @@ mod test { content_access.set_content_price(&creator, &content_id, &100); // Verify price is set - assert_eq!(content_access.get_content_price(&creator, &content_id), Some(100)); + assert_eq!( + content_access.get_content_price(&creator, &content_id), + Some(100) + ); // Buyer unlocks content content_access.unlock_content(&buyer, &creator, content_id, &2000); // expiry far in future @@ -739,12 +739,22 @@ mod test { let addr = env .register_stellar_asset_contract_v2(admin.clone()) .address(); - (addr.clone(), TokenClient::new(env, &addr), StellarAssetClient::new(env, &addr)) + ( + addr.clone(), + TokenClient::new(env, &addr), + StellarAssetClient::new(env, &addr), + ) } fn setup( env: &Env, - ) -> (TreasuryClient<'_>, Address, Address, TokenClient<'_>, Address) { + ) -> ( + TreasuryClient<'_>, + Address, + Address, + TokenClient<'_>, + Address, + ) { env.mock_all_auths(); let admin = Address::generate(env); let depositor = Address::generate(env); diff --git a/contract/contracts/treasury/src/lib.rs b/contract/contracts/treasury/src/lib.rs index a9325baa..4f461ac1 100644 --- a/contract/contracts/treasury/src/lib.rs +++ b/contract/contracts/treasury/src/lib.rs @@ -31,10 +31,8 @@ impl Treasury { env.storage().instance().set(&PAUSED, &false); env.storage().instance().set(&MIN_BALANCE, &0i128); - env.events().publish( - (Symbol::new(&env, "initialized"),), - (admin, token_address), - ); + env.events() + .publish((Symbol::new(&env, "initialized"),), (admin, token_address)); } /// Pause (`true`) or unpause (`false`) the contract. diff --git a/contract/contracts/treasury/src/test.rs b/contract/contracts/treasury/src/test.rs index 645e818a..cee7e8a1 100644 --- a/contract/contracts/treasury/src/test.rs +++ b/contract/contracts/treasury/src/test.rs @@ -856,15 +856,13 @@ fn test_initialize_emits_event() { let events = env.events().all(); let init_event = events.iter().find(|e| { - e.1.first().is_some_and(|t| { - t.try_into_val(&env).ok() == Some(Symbol::new(&env, "initialized")) - }) + e.1.first() + .is_some_and(|t| t.try_into_val(&env).ok() == Some(Symbol::new(&env, "initialized"))) }); assert!(init_event.is_some(), "initialized event must be emitted"); let event = init_event.unwrap(); - let (emitted_admin, emitted_token): (Address, Address) = - event.2.try_into_val(&env).unwrap(); + let (emitted_admin, emitted_token): (Address, Address) = event.2.try_into_val(&env).unwrap(); assert_eq!(emitted_admin, admin); assert_eq!(emitted_token, token_address); } @@ -885,9 +883,8 @@ fn test_set_paused_emits_event() { let events = env.events().all(); let paused_event = events.iter().find(|e| { - e.1.first().is_some_and(|t| { - t.try_into_val(&env).ok() == Some(Symbol::new(&env, "paused_set")) - }) + e.1.first() + .is_some_and(|t| t.try_into_val(&env).ok() == Some(Symbol::new(&env, "paused_set"))) }); assert!(paused_event.is_some(), "paused_set event must be emitted"); diff --git a/contract/contracts/treasury/src/tests/error_tests.rs b/contract/contracts/treasury/src/tests/error_tests.rs index 98fd583a..b74ac9b2 100644 --- a/contract/contracts/treasury/src/tests/error_tests.rs +++ b/contract/contracts/treasury/src/tests/error_tests.rs @@ -17,7 +17,15 @@ fn create_token_contract<'a>( (contract_address, token_client, admin_client) } -fn setup(env: &Env) -> (TreasuryClient<'_>, Address, Address, Address, TokenClient<'_>) { +fn setup( + env: &Env, +) -> ( + TreasuryClient<'_>, + Address, + Address, + Address, + TokenClient<'_>, +) { env.mock_all_auths(); let admin = Address::generate(env);