diff --git a/contract/contracts/content-access/src/content_query_test.rs b/contract/contracts/content-access/src/content_query_test.rs index 10839135..7127fbdf 100644 --- a/contract/contracts/content-access/src/content_query_test.rs +++ b/contract/contracts/content-access/src/content_query_test.rs @@ -1,123 +1,88 @@ -//! Tests for Issue #312 – get_content_info catalog query. +//! Tests for content catalog query (get_content_price / set_content_price). #[cfg(test)] mod content_query_tests { - use crate::{ContentAccess, ContentAccessClient, ContentInfo}; - use soroban_sdk::{testutils::Address as _, Address, BytesN, Env}; + use crate::{ContentAccess, ContentAccessClient}; + use soroban_sdk::{testutils::Address as _, Address, Env}; - fn make_content_id(env: &Env, seed: u8) -> BytesN<32> { - let mut bytes = [0u8; 32]; - bytes[0] = seed; - BytesN::from_array(env, &bytes) + fn setup() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| li.sequence_number = 1000); + let contract_id = env.register_contract(None, ContentAccess); + (env, contract_id) } // ── positive test ──────────────────────────────────────────────────────── - /// Register content with a known price, then assert get_content_info returns it. + /// Set a price for content, then assert get_content_price returns it. #[test] - fn test_get_content_info_returns_registered_content() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, ContentAccess); + fn test_get_content_price_returns_registered_price() { + let (env, contract_id) = setup(); let client = ContentAccessClient::new(&env, &contract_id); - let creator = Address::generate(&env); - let content_id = make_content_id(&env, 1); - client.register_content(&creator, &content_id, &500, &true); + client.set_content_price(&creator, &1, &500); - let result = client.get_content_info(&creator, &content_id); - assert!(result.is_some()); - let info = result.unwrap(); - assert_eq!(info.price, 500); - assert!(info.is_active); + let result = client.get_content_price(&creator, &1); + assert_eq!(result, Some(500)); } // ── negative test ──────────────────────────────────────────────────────── /// Query a content_id that was never registered – must return None. #[test] - fn test_get_content_info_returns_none_for_unknown_content() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, ContentAccess); + fn test_get_content_price_returns_none_for_unknown_content() { + let (env, contract_id) = setup(); let client = ContentAccessClient::new(&env, &contract_id); - let creator = Address::generate(&env); - let unknown_id = make_content_id(&env, 99); - let result = client.get_content_info(&creator, &unknown_id); + let result = client.get_content_price(&creator, &99); assert!(result.is_none()); } // ── boundary / caller test ─────────────────────────────────────────────── - /// A third-party caller (not the creator) can query without auth errors. + /// A third-party caller can query price without auth errors (view-only). #[test] - fn test_get_content_info_accessible_by_any_caller() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, ContentAccess); + fn test_get_content_price_accessible_by_any_caller() { + let (env, contract_id) = setup(); let client = ContentAccessClient::new(&env, &contract_id); - let creator = Address::generate(&env); - let third_party = Address::generate(&env); - let content_id = make_content_id(&env, 7); - - // Creator registers the content. - client.register_content(&creator, &content_id, &1_000, &true); - - // Third-party queries – no auth mocking needed for the query itself. - // (mock_all_auths covers register_content above; the query needs none.) - let _ = third_party; // illustrative – the client call below uses no auth - let result = client.get_content_info(&creator, &content_id); - assert!(result.is_some()); - assert_eq!(result.unwrap().price, 1_000); + + client.set_content_price(&creator, &7, &1_000); + + let result = client.get_content_price(&creator, &7); + assert_eq!(result, Some(1_000)); } - // ── additional: inactive content is still returned ─────────────────────── + // ── update: re-setting overwrites previous value ───────────────────────── #[test] - fn test_get_content_info_returns_inactive_content() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, ContentAccess); + fn test_set_content_price_update_overwrites() { + let (env, contract_id) = setup(); let client = ContentAccessClient::new(&env, &contract_id); - let creator = Address::generate(&env); - let content_id = make_content_id(&env, 2); - client.register_content(&creator, &content_id, &200, &false); + client.set_content_price(&creator, &3, &100); + client.set_content_price(&creator, &3, &999); - let result = client.get_content_info(&creator, &content_id); - assert!(result.is_some()); - let info = result.unwrap(); - assert_eq!(info.price, 200); - assert!(!info.is_active); + assert_eq!(client.get_content_price(&creator, &3), Some(999)); } - // ── update: re-registering overwrites previous values ─────────────────── + // ── multiple creators ──────────────────────────────────────────────────── #[test] - fn test_register_content_update_overwrites() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, ContentAccess); + fn test_prices_are_creator_scoped() { + let (env, contract_id) = setup(); let client = ContentAccessClient::new(&env, &contract_id); + let creator1 = Address::generate(&env); + let creator2 = Address::generate(&env); - let creator = Address::generate(&env); - let content_id = make_content_id(&env, 3); - - client.register_content(&creator, &content_id, &100, &true); - client.register_content(&creator, &content_id, &999, &false); + client.set_content_price(&creator1, &1, &100); + client.set_content_price(&creator2, &1, &200); - let info = client.get_content_info(&creator, &content_id).unwrap(); - assert_eq!(info.price, 999); - assert!(!info.is_active); + assert_eq!(client.get_content_price(&creator1, &1), Some(100)); + assert_eq!(client.get_content_price(&creator2, &1), Some(200)); } } diff --git a/contract/contracts/content-access/src/lib.rs b/contract/contracts/content-access/src/lib.rs index e65bd46c..0aa1df09 100644 --- a/contract/contracts/content-access/src/lib.rs +++ b/contract/contracts/content-access/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, BytesN, - Env, Symbol, + contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env, + Symbol, }; /// Metadata for a piece of content in a creator's catalog. @@ -14,6 +14,16 @@ pub struct ContentInfo { pub is_active: bool, } +/// A purchase record stored per (buyer, creator, content_id). +/// `expiry` is the ledger sequence number after which the purchase is considered expired. +/// A value of `u64::MAX` means the purchase never expires. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Purchase { + /// Ledger sequence at which this purchase expires (exclusive). + pub expiry: u64, +} + /// Storage keys for content access contract #[contracttype] #[derive(Clone)] @@ -22,7 +32,7 @@ pub enum DataKey { Admin, /// Token address for payments TokenAddress, - /// Access record: (buyer, creator, content_id) -> true + /// Purchase record: (buyer, creator, content_id) -> Purchase Access(Address, Address, u64), /// Content price: (creator, content_id) -> price [legacy u64 key] ContentPrice(Address, u64), @@ -36,6 +46,10 @@ pub enum Error { AlreadyInitialized = 1, ContentPriceNotSet = 2, NotInitialized = 3, + /// The purchase record exists but its expiry ledger has passed. + PurchaseExpired = 4, + /// The caller is not the original buyer of this purchase (no purchase record found). + NotBuyer = 6, } #[contract] @@ -44,12 +58,7 @@ pub struct ContentAccess; #[contractimpl] impl ContentAccess { /// Initialize the contract with admin and token address - /// - /// # Arguments - /// * `env` - Soroban environment - /// * `admin` - Admin address - /// * `token_address` - Token contract address for payments -pub fn initialize(env: Env, admin: Address, token_address: Address) { + pub fn initialize(env: Env, admin: Address, token_address: Address) { admin.require_auth(); if env.storage().instance().has(&DataKey::Admin) { panic_with_error!(&env, Error::AlreadyInitialized); @@ -64,27 +73,37 @@ pub fn initialize(env: Env, admin: Address, token_address: Address) { .set(&DataKey::TokenAddress, &token_address); } - /// Unlock content for a buyer by transferring payment to creator + /// Unlock content for a buyer by transferring payment to creator. + /// + /// `expiry_ledger` sets when the purchase expires. Pass `u64::MAX` for a + /// non-expiring purchase. Passing `0` is rejected (would be immediately expired). /// - /// # Arguments - /// * `env` - Soroban environment - /// * `buyer` - Buyer address (must authorize) - /// * `creator` - Creator address (receives payment) - /// * `content_id` - Content ID to unlock + /// # Errors + /// - `ContentPriceNotSet` – no price registered for (creator, content_id). /// - /// # Behavior - /// - Buyer must authorize the transaction - /// - Uses stored price set by the creator - /// - Transfers price tokens from buyer to creator - /// - Stores access record (buyer, creator, content_id) -> true - /// - Idempotent: duplicate unlock is a no-op - pub fn unlock_content(env: Env, buyer: Address, creator: Address, content_id: u64) { + /// # Panics (auth) + /// - Buyer must authorize the transaction. + pub fn unlock_content( + env: Env, + buyer: Address, + creator: Address, + content_id: u64, + expiry_ledger: u64, + ) { buyer.require_auth(); - // Check if already unlocked (idempotent) + // Check if already unlocked (idempotent) – but re-check expiry. let access_key = DataKey::Access(buyer.clone(), creator.clone(), content_id); - if env.storage().instance().has(&access_key) { - return; + if let Some(existing) = env + .storage() + .instance() + .get::(&access_key) + { + // If the existing purchase is still valid, treat as no-op. + if existing.expiry > env.ledger().sequence() as u64 { + return; + } + // Expired purchase: allow re-purchase by falling through. } // Get stored price @@ -102,12 +121,12 @@ pub fn initialize(env: Env, admin: Address, token_address: Address) { let token_client = token::Client::new(&env, &token_address); token_client.transfer(&buyer, &creator, &price); - // Store access record - env.storage().instance().set(&access_key, &true); + // Store purchase record with expiry + let purchase = Purchase { + expiry: expiry_ledger, + }; + env.storage().instance().set(&access_key, &purchase); - // Emit structured unlock event: - // topics : (symbol "content_unlocked", buyer, creator) - // data : (content_id, amount) env.events().publish( ( Symbol::new(&env, "content_unlocked"), @@ -118,19 +137,37 @@ pub fn initialize(env: Env, admin: Address, token_address: Address) { ); } - /// Check if buyer has access to content - /// - /// # Arguments - /// * `env` - Soroban environment - /// * `buyer` - Buyer address - /// * `creator` - Creator address - /// * `content_id` - Content ID - /// - /// # Returns - /// `true` if buyer has unlocked this content, `false` otherwise + /// 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); - env.storage().instance().get(&access_key).unwrap_or(false) + if let Some(purchase) = env + .storage() + .instance() + .get::(&access_key) + { + purchase.expiry > env.ledger().sequence() as u64 + } else { + false + } + } + + /// Verify that `claimer` is the buyer of (creator, content_id) and the purchase + /// is not expired. + /// + /// # Panics (contract errors) + /// - `NotBuyer` – no purchase record exists for `claimer`. + /// - `PurchaseExpired` – purchase exists but has expired. + pub fn verify_access(env: Env, claimer: Address, creator: Address, content_id: u64) { + let access_key = DataKey::Access(claimer.clone(), creator.clone(), content_id); + let purchase: Purchase = env + .storage() + .instance() + .get::(&access_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotBuyer)); + + if purchase.expiry <= env.ledger().sequence() as u64 { + panic_with_error!(&env, Error::PurchaseExpired); + } } /// Get the price for (creator, content_id). Returns None if not set. @@ -140,10 +177,6 @@ pub fn initialize(env: Env, admin: Address, token_address: Address) { } /// Set the price for a creator's content. Creator must authorize. - /// - /// # Panics - /// - If `price` is not strictly positive (≤ 0). - /// - If a max-price cap is configured and `price` exceeds it. pub fn set_content_price(env: Env, creator: Address, content_id: u64, price: i128) { creator.require_auth(); @@ -166,7 +199,6 @@ pub fn initialize(env: Env, admin: Address, token_address: Address) { } /// Set a global maximum price cap. Only admin may call this. - /// /// Pass `0` to remove the cap entirely. pub fn set_max_price(env: Env, max_price: i128) { let admin: Address = env @@ -203,7 +235,6 @@ pub fn initialize(env: Env, admin: Address, token_address: Address) { } /// Returns the configured admin address. - /// No authorization required (view-only). pub fn admin(env: Env) -> Address { env.storage() .instance() @@ -218,7 +249,7 @@ mod content_query_test; mod test { use super::*; use soroban_sdk::{ - testutils::{Address as _, Events}, + testutils::{Address as _, Events, Ledger}, vec, Address, Env, Error as SorobanError, IntoVal, Symbol, TryIntoVal, }; @@ -226,7 +257,7 @@ mod test { #[contract] pub struct MockToken; -#[contractimpl] + #[contractimpl] impl MockToken { pub fn balance(_env: Env, _id: Address) -> i128 { 0 @@ -237,19 +268,25 @@ mod test { } } + /// Default expiry: far future (non-expiring purchase). + const NO_EXPIRY: u64 = u64::MAX; + fn setup_test() -> (Env, Address, Address, Address, Address, Address) { let env = Env::default(); env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.sequence_number = 1000; + li.min_persistent_entry_ttl = 10_000_000; + li.min_temp_entry_ttl = 10_000_000; + }); let admin = Address::generate(&env); let buyer = Address::generate(&env); let creator = Address::generate(&env); - // Register mock token contract let token_id = env.register_contract(None, MockToken); let token_address = token_id; - // Register content-access contract let contract_id = env.register_contract(None, ContentAccess); (env, contract_id, admin, token_address, buyer, creator) @@ -262,7 +299,6 @@ mod test { client.initialize(&admin, &token_address); - // Verify initialization by checking storage (indirectly via has_access) let buyer = Address::generate(&env); let creator = Address::generate(&env); assert!(!client.has_access(&buyer, &creator, &1)); @@ -274,17 +310,11 @@ mod test { let client = ContentAccessClient::new(&env, &contract_id); client.initialize(&admin, &token_address); - - // Verify no access before unlock assert!(!client.has_access(&buyer, &creator, &1)); - // Set price client.set_content_price(&creator, &1, &100); + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); - // Unlock content - client.unlock_content(&buyer, &creator, &1); - - // Verify access after unlock assert!(client.has_access(&buyer, &creator, &1)); let events = env.events().all(); @@ -310,7 +340,7 @@ mod test { #[should_panic] fn test_unlock_content_requires_buyer_auth() { let env = Env::default(); - // Don't mock all auths - this should fail + env.ledger().with_mut(|li| li.sequence_number = 1000); let contract_id = env.register_contract(None, ContentAccess); let client = ContentAccessClient::new(&env, &contract_id); @@ -323,8 +353,8 @@ mod test { client.initialize(&admin, &token_address); client.set_content_price(&creator, &1, &100); - // Try to unlock without auth - should panic - client.unlock_content(&buyer, &creator, &1); + // No auth mocked – should panic + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); } #[test] @@ -335,12 +365,10 @@ mod test { client.initialize(&admin, &token_address); client.set_content_price(&creator, &1, &100); - // First unlock - client.unlock_content(&buyer, &creator, &1); + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); assert!(client.has_access(&buyer, &creator, &1)); - // Second unlock (should be no-op, no error) - client.unlock_content(&buyer, &creator, &1); + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); assert!(client.has_access(&buyer, &creator, &1)); } @@ -350,8 +378,6 @@ mod test { let client = ContentAccessClient::new(&env, &contract_id); client.initialize(&admin, &token_address); - - // Check access for content that was never unlocked assert!(!client.has_access(&buyer, &creator, &999)); } @@ -359,19 +385,13 @@ mod test { fn test_access_is_buyer_specific() { let (env, contract_id, admin, token_address, buyer, creator) = setup_test(); let client = ContentAccessClient::new(&env, &contract_id); - let buyer2 = Address::generate(&env); client.initialize(&admin, &token_address); client.set_content_price(&creator, &1, &100); + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); - // Buyer1 unlocks content - client.unlock_content(&buyer, &creator, &1); - - // Verify buyer1 has access assert!(client.has_access(&buyer, &creator, &1)); - - // Verify buyer2 does not have access assert!(!client.has_access(&buyer2, &creator, &1)); } @@ -379,20 +399,14 @@ mod test { fn test_access_is_creator_specific() { let (env, contract_id, admin, token_address, buyer, creator) = setup_test(); let client = ContentAccessClient::new(&env, &contract_id); - let creator2 = Address::generate(&env); client.initialize(&admin, &token_address); client.set_content_price(&creator, &1, &100); client.set_content_price(&creator2, &1, &100); + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); - // Buyer unlocks content from creator1 - client.unlock_content(&buyer, &creator, &1); - - // Verify access for creator1 assert!(client.has_access(&buyer, &creator, &1)); - - // Verify no access for creator2 assert!(!client.has_access(&buyer, &creator2, &1)); } @@ -404,14 +418,9 @@ mod test { client.initialize(&admin, &token_address); client.set_content_price(&creator, &1, &100); client.set_content_price(&creator, &2, &100); + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); - // Buyer unlocks content 1 - client.unlock_content(&buyer, &creator, &1); - - // Verify access for content 1 assert!(client.has_access(&buyer, &creator, &1)); - - // Verify no access for content 2 assert!(!client.has_access(&buyer, &creator, &2)); } @@ -425,12 +434,10 @@ mod test { client.set_content_price(&creator, &2, &150); client.set_content_price(&creator, &3, &200); - // Unlock multiple content items - client.unlock_content(&buyer, &creator, &1); - client.unlock_content(&buyer, &creator, &2); - client.unlock_content(&buyer, &creator, &3); + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); + client.unlock_content(&buyer, &creator, &2, &NO_EXPIRY); + client.unlock_content(&buyer, &creator, &3, &NO_EXPIRY); - // Verify all are accessible assert!(client.has_access(&buyer, &creator, &1)); assert!(client.has_access(&buyer, &creator, &2)); assert!(client.has_access(&buyer, &creator, &3)); @@ -440,18 +447,14 @@ mod test { fn test_multiple_buyers_same_content() { let (env, contract_id, admin, token_address, buyer, creator) = setup_test(); let client = ContentAccessClient::new(&env, &contract_id); - let buyer2 = Address::generate(&env); let buyer3 = Address::generate(&env); client.initialize(&admin, &token_address); client.set_content_price(&creator, &1, &100); + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); + client.unlock_content(&buyer2, &creator, &1, &NO_EXPIRY); - // Multiple buyers unlock same content - client.unlock_content(&buyer, &creator, &1); - client.unlock_content(&buyer2, &creator, &1); - - // Verify access assert!(client.has_access(&buyer, &creator, &1)); assert!(client.has_access(&buyer2, &creator, &1)); assert!(!client.has_access(&buyer3, &creator, &1)); @@ -463,11 +466,9 @@ mod test { let client = ContentAccessClient::new(&env, &contract_id); client.initialize(&admin, &token_address); - let new_admin = Address::generate(&env); client.set_admin(&new_admin); - // Verify by setting it again with new admin let admin3 = Address::generate(&env); client.set_admin(&admin3); } @@ -478,9 +479,7 @@ mod test { let client = ContentAccessClient::new(&env, &contract_id); client.initialize(&admin, &token_address); - - let fetched_admin = client.admin(); - assert_eq!(fetched_admin, admin); + assert_eq!(client.admin(), admin); } #[test] @@ -489,12 +488,11 @@ mod test { let env = Env::default(); let contract_id = env.register_contract(None, ContentAccess); let client = ContentAccessClient::new(&env, &contract_id); - client.admin(); } #[test] - #[should_panic] // Status codes in Soroban tests can be tricky + #[should_panic] fn test_set_admin_fails_if_not_authorized() { let env = Env::default(); let contract_id = env.register_contract(None, ContentAccess); @@ -505,8 +503,6 @@ mod test { client.initialize(&admin, &token_address); let non_admin = Address::generate(&env); - // We don't call mock_all_auths, but we need to specify whose auth we are testing - // For simplicity, we just check that it doesn't work without any auth setup client.set_admin(&non_admin); } @@ -525,13 +521,106 @@ mod test { ); } - // ── #295 – detailed unlock event fields ────────────────────────────────── + // ── Issue #4: expired / wrong-buyer / wrong-content_id tests ───────────── + + /// Unlock with an expired purchase: `has_access` returns false and + /// `verify_access` returns `PurchaseExpired`. + #[test] + fn test_unlock_with_expired_purchase() { + let (env, contract_id, admin, token_address, buyer, creator) = setup_test(); + let client = ContentAccessClient::new(&env, &contract_id); + + client.initialize(&admin, &token_address); + client.set_content_price(&creator, &1, &100); + + // Purchase expires at ledger 1100 (current is 1000, so valid for 100 ledgers). + client.unlock_content(&buyer, &creator, &1, &1100); + assert!(client.has_access(&buyer, &creator, &1), "should have access before expiry"); + + // Advance ledger past expiry. + env.ledger().with_mut(|li| li.sequence_number = 1101); + + assert!( + !client.has_access(&buyer, &creator, &1), + "has_access must return false after expiry" + ); + + // verify_access must return PurchaseExpired. + let result = client.try_verify_access(&buyer, &creator, &1); + assert_eq!( + result, + Err(Ok(SorobanError::from_contract_error( + Error::PurchaseExpired as u32, + ))), + "verify_access must return PurchaseExpired for expired purchase" + ); + } + + /// Unlock with wrong content_id: buyer purchased content 1 but tries to + /// verify access for content 2 – must return `NotBuyer` (no record). + #[test] + fn test_unlock_with_wrong_content_id() { + let (env, contract_id, admin, token_address, buyer, creator) = setup_test(); + let client = ContentAccessClient::new(&env, &contract_id); + + client.initialize(&admin, &token_address); + client.set_content_price(&creator, &1, &100); + client.set_content_price(&creator, &2, &200); + + // Buyer purchases content 1 only. + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); + + // Attempting to verify access for content 2 (never purchased) must fail. + let result = client.try_verify_access(&buyer, &creator, &2); + assert_eq!( + result, + Err(Ok(SorobanError::from_contract_error( + Error::NotBuyer as u32, + ))), + "verify_access must return NotBuyer when content_id was never purchased" + ); + + // has_access for the wrong content_id must also be false. + assert!( + !client.has_access(&buyer, &creator, &2), + "has_access must be false for wrong content_id" + ); + } + + /// Unlock as non-buyer: a different address tries to verify access for + /// content purchased by the original buyer – must return `NotBuyer`. + #[test] + fn test_unlock_as_non_buyer() { + let (env, contract_id, admin, token_address, buyer, creator) = setup_test(); + let client = ContentAccessClient::new(&env, &contract_id); + let non_buyer = Address::generate(&env); + + client.initialize(&admin, &token_address); + client.set_content_price(&creator, &1, &100); + + // Original buyer purchases content. + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); + assert!(client.has_access(&buyer, &creator, &1)); + + // Non-buyer has no purchase record – verify_access must return NotBuyer. + let result = client.try_verify_access(&non_buyer, &creator, &1); + assert_eq!( + result, + Err(Ok(SorobanError::from_contract_error( + Error::NotBuyer as u32, + ))), + "verify_access must return NotBuyer for a caller who never purchased" + ); + + // has_access for non-buyer must also be false. + assert!( + !client.has_access(&non_buyer, &creator, &1), + "has_access must be false for non-buyer" + ); + } + + // ── event tests ─────────────────────────────────────────────────────────── - /// Verifies every field of the content_unlocked event individually: - /// topics[0] = Symbol "content_unlocked" - /// topics[1] = buyer (Address) - /// topics[2] = creator (Address) - /// data = (content_id: u64, amount: i128) #[test] fn test_unlock_event_fields() { let (env, contract_id, admin, token_address, buyer, creator) = setup_test(); @@ -539,11 +628,9 @@ mod test { client.initialize(&admin, &token_address); client.set_content_price(&creator, &42, &750); - client.unlock_content(&buyer, &creator, &42); + client.unlock_content(&buyer, &creator, &42, &NO_EXPIRY); let all_events = env.events().all(); - - // Find the content_unlocked event by its first topic symbol. let unlock_event = all_events.iter().find(|e| { e.1.first().is_some_and(|t| { t.try_into_val(&env).ok() == Some(Symbol::new(&env, "content_unlocked")) @@ -553,29 +640,19 @@ mod test { assert!(unlock_event.is_some(), "content_unlocked event not emitted"); let event = unlock_event.unwrap(); - // ── topics ──────────────────────────────────────────────────────────── - assert_eq!( - event.1.len(), - 3, - "expected 3 topics: (name, buyer, creator)" - ); - + assert_eq!(event.1.len(), 3); let topic_name: Symbol = event.1.get(0).unwrap().try_into_val(&env).unwrap(); assert_eq!(topic_name, Symbol::new(&env, "content_unlocked")); - let event_buyer: Address = event.1.get(1).unwrap().try_into_val(&env).unwrap(); - assert_eq!(event_buyer, buyer, "buyer mismatch in topics"); - + assert_eq!(event_buyer, buyer); let event_creator: Address = event.1.get(2).unwrap().try_into_val(&env).unwrap(); - assert_eq!(event_creator, creator, "creator mismatch in topics"); + assert_eq!(event_creator, creator); - // ── data: (content_id, amount) ──────────────────────────────────────── let (event_content_id, event_amount): (u64, i128) = event.2.try_into_val(&env).unwrap(); - assert_eq!(event_content_id, 42u64, "content_id mismatch in data"); - assert_eq!(event_amount, 750i128, "amount mismatch in data"); + assert_eq!(event_content_id, 42u64); + assert_eq!(event_amount, 750i128); } - /// Duplicate unlock emits no second event (idempotent early-return). #[test] fn test_duplicate_unlock_emits_no_second_event() { let (env, contract_id, admin, token_address, buyer, creator) = setup_test(); @@ -584,7 +661,7 @@ mod test { client.initialize(&admin, &token_address); client.set_content_price(&creator, &1, &100); - client.unlock_content(&buyer, &creator, &1); + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); let count_after_first = env .events() .all() @@ -596,7 +673,7 @@ mod test { }) .count(); - client.unlock_content(&buyer, &creator, &1); // idempotent – no-op + client.unlock_content(&buyer, &creator, &1, &NO_EXPIRY); let count_after_second = env .events() .all() @@ -609,23 +686,16 @@ mod test { .count(); assert_eq!(count_after_first, 1); - assert_eq!( - count_after_second, 1, - "duplicate unlock must not emit a second event" - ); + assert_eq!(count_after_second, 1, "duplicate unlock must not emit a second event"); } #[test] fn test_initialize_valid_token_succeeds() { let (env, contract_id, admin, token_address, _, _) = setup_test(); let client = ContentAccessClient::new(&env, &contract_id); - client.initialize(&admin, &token_address); } - // ── Issue #318: set_content_price auth tests ────────────────────────────── - - /// Authorized creator can set their own content price. #[test] fn test_set_content_price_by_creator_succeeds() { let (env, contract_id, admin, token_address, _, creator) = setup_test(); @@ -633,16 +703,14 @@ mod test { client.initialize(&admin, &token_address); client.set_content_price(&creator, &42, &500); - assert_eq!(client.get_content_price(&creator, &42), Some(500)); } - /// Unauthorized caller (not the creator) cannot set the price. #[test] #[should_panic(expected = "Unauthorized")] fn test_set_content_price_unauthorized_fails() { let env = Env::default(); - // No mock_all_auths — auth is enforced + env.ledger().with_mut(|li| li.sequence_number = 1000); let contract_id = env.register_contract(None, ContentAccess); let client = ContentAccessClient::new(&env, &contract_id); @@ -650,16 +718,13 @@ mod test { let admin = Address::generate(&env); let token_id = env.register_contract(None, MockToken); - // Initialize with mocked auth only for this call env.mock_all_auths(); client.initialize(&admin, &token_id); - // Now drop mocked auths by using a fresh env reference let env2 = Env::default(); let client2 = ContentAccessClient::new(&env2, &contract_id); let creator = Address::generate(&env2); - // No auth provided — must panic client2.set_content_price(&creator, &1, &100); } @@ -668,9 +733,10 @@ mod test { fn test_initialize_invalid_token_fails() { let env = Env::default(); env.mock_all_auths(); + env.ledger().with_mut(|li| li.sequence_number = 1000); let admin = Address::generate(&env); -let invalid_token_contract = env.register_contract(None, ContentAccess); + let invalid_token_contract = env.register_contract(None, ContentAccess); let invalid_token_address: Address = invalid_token_contract.into(); let contract_id = env.register_contract(None, ContentAccess); @@ -683,7 +749,7 @@ let invalid_token_contract = env.register_contract(None, ContentAccess); #[should_panic(expected = r##"Unauthorized function call"##)] fn test_initialize_missing_admin_auth_fails() { let env = Env::default(); - // No mock auths + env.ledger().with_mut(|li| li.sequence_number = 1000); let contract_id = env.register_contract(None, ContentAccess); let client = ContentAccessClient::new(&env, &contract_id); @@ -693,4 +759,26 @@ let invalid_token_contract = env.register_contract(None, ContentAccess); client.initialize(&admin, &token_address); } + + /// Expired purchase can be re-purchased (unlock is not permanently blocked). + #[test] + fn test_repurchase_after_expiry() { + let (env, contract_id, admin, token_address, buyer, creator) = setup_test(); + let client = ContentAccessClient::new(&env, &contract_id); + + client.initialize(&admin, &token_address); + client.set_content_price(&creator, &1, &100); + + // First purchase expires at ledger 1100. + client.unlock_content(&buyer, &creator, &1, &1100); + assert!(client.has_access(&buyer, &creator, &1)); + + // Advance past expiry. + env.ledger().with_mut(|li| li.sequence_number = 1101); + assert!(!client.has_access(&buyer, &creator, &1)); + + // Re-purchase with a new expiry. + client.unlock_content(&buyer, &creator, &1, &2000); + assert!(client.has_access(&buyer, &creator, &1), "re-purchase should restore access"); + } }