diff --git a/Cargo.toml b/Cargo.toml index bbf17d4..fbbba63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,22 @@ path = "tests/integration/batch_operations.rs" name = "security_double_spending_tests" path = "tests/integration/security_double_spending_tests.rs" +[[test]] +name = "emergency_pause_flow" +path = "tests/integration/emergency_pause_flow.rs" + +[[test]] +name = "token_vesting_flow" +path = "tests/integration/token_vesting_flow.rs" + +[[test]] +name = "multi_admin_flow" +path = "tests/integration/multi_admin_flow.rs" + +[[test]] +name = "content_hash_flow" +path = "tests/integration/content_hash_flow.rs" + [[test]] name = "xcontract_call_tests" path = "tests/unit/xcontract_call_tests.rs" diff --git a/tests/integration/content_hash_flow.rs b/tests/integration/content_hash_flow.rs new file mode 100644 index 0000000..8da021e --- /dev/null +++ b/tests/integration/content_hash_flow.rs @@ -0,0 +1,173 @@ +//! Integration tests for course content hash verification (#250). +//! +//! Verifies that courses have content hashes, hashes can be set and updated, +//! hashes are queryable, and hash verification is optional during enrollment. + +mod fixtures; +use fixtures::setup_chainlearn_env; + +use progress_tracker::ProgressTrackerClient; +use soroban_sdk::{testutils::Address as _, testutils::Events as _, Symbol}; + +/// Course content hash defaults to "none" (unset). +#[test] +fn test_content_hash_defaults_to_unset() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + env.mock_all_auths(); + + let progress_client = ProgressTrackerClient::new(env, &setup.progress_contract_id); + let course_id = fixtures::create_sample_course(env, &progress_client); + + let hash = progress_client.get_course_content_hash(&course_id); + assert_eq!(hash, Symbol::new(env, "none")); + + // Also queryable via get_course. + let course = progress_client.get_course(&course_id); + assert_eq!(course.content_hash, Symbol::new(env, "none")); +} + +/// Admin can set and update the content hash. +#[test] +fn test_set_and_update_content_hash() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + let admin = &setup.admin; + env.mock_all_auths(); + + let progress_client = ProgressTrackerClient::new(env, &setup.progress_contract_id); + let course_id = fixtures::create_sample_course(env, &progress_client); + + // Set hash. + let hash_v1 = Symbol::new(env, "QmHashV1"); + progress_client.set_course_content_hash(&course_id, &hash_v1); + assert_eq!(progress_client.get_course_content_hash(&course_id), hash_v1); + + // Update hash. + let hash_v2 = Symbol::new(env, "QmHashV2"); + progress_client.set_course_content_hash(&course_id, &hash_v2); + assert_eq!(progress_client.get_course_content_hash(&course_id), hash_v2); + + // Unset hash by setting to "none". + progress_client.set_course_content_hash(&course_id, &Symbol::new(env, "none")); + assert_eq!( + progress_client.get_course_content_hash(&course_id), + Symbol::new(env, "none") + ); +} + +/// Content hash set event is emitted. +#[test] +fn test_content_hash_set_emits_event() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + let admin = &setup.admin; + env.mock_all_auths(); + + let progress_client = ProgressTrackerClient::new(env, &setup.progress_contract_id); + let course_id = fixtures::create_sample_course(env, &progress_client); + + let hash = Symbol::new(env, "QmTest"); + progress_client.set_course_content_hash(&course_id, &hash); + + let all = env.events().all(); + let (_, topics, _) = all.last().expect("no events emitted"); + let topics: soroban_sdk::Vec = topics.clone(); + let event_name: Symbol = topics.get(0).unwrap().into_val(env); + assert_eq!(event_name, Symbol::new(env, "content_hash_set")); +} + +/// Plain enroll skips hash verification (hash is optional). +#[test] +fn test_enroll_skips_hash_verification() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + env.mock_all_auths(); + + let progress_client = ProgressTrackerClient::new(env, &setup.progress_contract_id); + let course_id = fixtures::create_sample_course(env, &progress_client); + + // Set a hash on the course. + progress_client.set_course_content_hash(&course_id, &Symbol::new(env, "QmSecret")); + + // Plain enroll still succeeds — verification is optional. + let learner = Address::generate(env); + progress_client.enroll(&learner, &course_id); + + let progress = progress_client.get_progress(&learner, &course_id); + assert_eq!(progress.overall_progress, 0); +} + +/// enroll_checked accepts a matching hash. +#[test] +fn test_enroll_checked_accepts_matching_hash() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + env.mock_all_auths(); + + let progress_client = ProgressTrackerClient::new(env, &setup.progress_contract_id); + let course_id = fixtures::create_sample_course(env, &progress_client); + + let hash = Symbol::new(env, "QmCorrect"); + progress_client.set_course_content_hash(&course_id, &hash); + + let learner = Address::generate(env); + progress_client.enroll_checked(&learner, &course_id, &Some(hash)); + + let progress = progress_client.get_progress(&learner, &course_id); + assert_eq!(progress.overall_progress, 0); +} + +/// enroll_checked rejects a mismatched hash. +#[test] +#[should_panic(expected = "course content hash mismatch")] +fn test_enroll_checked_rejects_mismatched_hash() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + env.mock_all_auths(); + + let progress_client = ProgressTrackerClient::new(env, &setup.progress_contract_id); + let course_id = fixtures::create_sample_course(env, &progress_client); + + progress_client.set_course_content_hash(&course_id, &Symbol::new(env, "QmReal")); + + let learner = Address::generate(env); + progress_client.enroll_checked(&learner, &course_id, &Some(Symbol::new(env, "QmWrong"))); +} + +/// enroll_checked skips verification when the course has no hash set. +#[test] +fn test_enroll_checked_skips_when_hash_unset() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + env.mock_all_auths(); + + let progress_client = ProgressTrackerClient::new(env, &setup.progress_contract_id); + let course_id = fixtures::create_sample_course(env, &progress_client); + + // No hash set — verification is skipped even if the caller provides one. + let learner = Address::generate(env); + progress_client.enroll_checked(&learner, &course_id, &Some(Symbol::new(env, "anything"))); + + let progress = progress_client.get_progress(&learner, &course_id); + assert_eq!(progress.overall_progress, 0); +} + +/// enroll_checked with None skips verification even when hash is set. +#[test] +fn test_enroll_checked_none_skips_verification() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + env.mock_all_auths(); + + let progress_client = ProgressTrackerClient::new(env, &setup.progress_contract_id); + let course_id = fixtures::create_sample_course(env, &progress_client); + + progress_client.set_course_content_hash(&course_id, &Symbol::new(env, "QmSet")); + + let learner = Address::generate(env); + progress_client.enroll_checked(&learner, &course_id, &None); + + let progress = progress_client.get_progress(&learner, &course_id); + assert_eq!(progress.overall_progress, 0); +} diff --git a/tests/integration/emergency_pause_flow.rs b/tests/integration/emergency_pause_flow.rs new file mode 100644 index 0000000..bc9cce3 --- /dev/null +++ b/tests/integration/emergency_pause_flow.rs @@ -0,0 +1,240 @@ +//! Integration tests for emergency pause across all three contracts (#281). +//! +//! Verifies that emergency pause works correctly on learn-token, progress-tracker, +//! and credential-nft: pause prevents state changes, unpause restores operations, +//! read-only functions still work while paused, and events are emitted. + +mod fixtures; +use fixtures::setup_chainlearn_env; + +use learn_token::{LearnTokenClient, AdminRole}; +use progress_tracker::ProgressTrackerClient; +use credential_nft::CredentialNftClient; +use soroban_sdk::{testutils::Address as _, testutils::Events as _, Address, Symbol, Vec}; + +// ── Issue #281: learn-token emergency pause ────────────────────────────── + +/// Pause prevents transfers, minting, burning, and claiming rewards. +/// Unpause restores all operations. Read-only functions still work. +/// Pause/unpause events are emitted. +#[test] +fn test_token_emergency_pause_prevents_state_changes() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + let admin = &setup.admin; + let learner = &setup.learner; + env.mock_all_auths(); + + let token_client = LearnTokenClient::new(env, &setup.token_contract_id); + let progress_client = ProgressTrackerClient::new(env, &setup.progress_contract_id); + + // Mint tokens to the learner so we have something to transfer/burn. + token_client.mint(admin, learner, &10_000); + assert_eq!(token_client.balance(learner), 10_000); + + // ── Pause ── + token_client.pause(admin); + assert!(token_client.is_paused()); + + // Events: paused event emitted + { + let all = env.events().all(); + let (_, topics, _) = all.last().expect("no events emitted"); + let topics: soroban_sdk::Vec = topics.clone(); + let event_name: Symbol = topics.get(0).unwrap().into_val(env); + assert_eq!(event_name, Symbol::new(env, "paused")); + } + + // ── Transfers revert when paused ── + let recipient = Address::generate(env); + assert!(token_client.try_transfer(learner, &recipient, &1000).is_err()); + assert_eq!(token_client.balance(learner), 10_000); // unchanged + + // ── Minting reverts when paused ── + assert!(token_client.try_mint(admin, learner, &5000).is_err()); + + // ── Burning reverts when paused ── + assert!(token_client.try_burn(learner, &1000).is_err()); + + // ── Read-only functions still work ── + assert_eq!(token_client.balance(learner), 10_000); + assert_eq!(token_client.total_supply(), 10_000); + assert_eq!(token_client.name(), soroban_sdk::String::from_str(env, "CLearn")); + assert_eq!(token_client.symbol(), soroban_sdk::String::from_str(env, "CLRN")); + assert_eq!(token_client.decimals(), 7); + + // ── Unpause ── + token_client.unpause(admin); + assert!(!token_client.is_paused()); + + // Events: unpaused event emitted + { + let all = env.events().all(); + let (_, topics, _) = all.last().expect("no events emitted"); + let topics: soroban_sdk::Vec = topics.clone(); + let event_name: Symbol = topics.get(0).unwrap().into_val(env); + assert_eq!(event_name, Symbol::new(env, "unpaused")); + } + + // ── Operations work again after unpause ── + token_client.transfer(learner, &recipient, &1000); + assert_eq!(token_client.balance(learner), 9_000); + assert_eq!(token_client.balance(&recipient), 1_000); + + token_client.mint(admin, learner, &500); + assert_eq!(token_client.balance(learner), 9_500); + + token_client.burn(learner, &500); + assert_eq!(token_client.balance(learner), 9_000); +} + +// ── Issue #281: progress-tracker emergency pause ───────────────────────── + +/// Pause prevents enrollment, module completion, and quiz submissions. +/// Unpause restores operations. Read-only functions still work. +#[test] +fn test_progress_tracker_emergency_pause() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + let learner = &setup.learner; + env.mock_all_auths(); + + let progress_client = ProgressTrackerClient::new(env, &setup.progress_contract_id); + + // Create a course before pausing. + let course_id = fixtures::create_sample_course(env, &progress_client); + + // ── Pause ── + progress_client.emergency_pause(); + + // ── Enrollment reverts when paused ── + let new_learner = Address::generate(env); + assert!(progress_client.try_enroll(&new_learner, &course_id).is_err()); + + // ── Module completion reverts when paused ── + // Enroll a learner before pausing so we can test complete_module. + progress_client.unpause(); + progress_client.enroll(learner, &course_id); + progress_client.emergency_pause(); + assert!( + progress_client + .try_complete_module(learner, &course_id, &Symbol::new(env, "mod_basics")) + .is_err() + ); + + // ── Quiz submission reverts when paused ── + assert!( + progress_client + .try_submit_quiz_score(learner, &course_id, &Symbol::new(env, "quiz_midterm"), &80) + .is_err() + ); + + // ── Read-only functions still work ── + let course = progress_client.get_course(&course_id); + assert_eq!(course.course_id, course_id); + assert_eq!(course.total_modules, 3); + + let progress = progress_client.get_progress(learner, &course_id); + assert_eq!(progress.overall_progress, 0); + + // ── Unpause ── + progress_client.unpause(); + + // ── Operations resume ── + progress_client.complete_module(learner, &course_id, &Symbol::new(env, "mod_basics")); + let progress = progress_client.get_progress(learner, &course_id); + assert!(progress.overall_progress > 0); +} + +// ── Issue #281: credential-nft emergency pause ─────────────────────────── + +/// Pause prevents minting, revoking, and renewing credentials. +/// Unpause restores operations. Read-only functions still work. +#[test] +fn test_credential_nft_emergency_pause() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + let learner = &setup.learner; + env.mock_all_auths(); + + let credential_client = CredentialNftClient::new(env, &setup.credential_contract_id); + let progress_client = ProgressTrackerClient::new(env, &setup.progress_contract_id); + + // Complete a course so we can attempt to mint credentials. + let course_id = Symbol::new(env, "rust_101"); + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + module_ids.push_back(Symbol::new(env, "mod_2")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(Symbol::new(env, "quiz_1")); + progress_client.create_course(&course_id, &2, &1, &module_ids, &quiz_ids); + progress_client.enroll(learner, &course_id); + progress_client.complete_module(learner, &course_id, &Symbol::new(env, "mod_1")); + progress_client.complete_module(learner, &course_id, &Symbol::new(env, "mod_2")); + progress_client.submit_quiz_score(learner, &course_id, &Symbol::new(env, "quiz_1"), &85); + + // Mint a credential before pausing so we can test revoke. + let uri = Symbol::new(env, "ipfs_meta"); + let cred_id = credential_client.mint_credential(learner, &course_id, &85, &uri); + + // ── Pause ── + credential_client.emergency_pause(); + + // ── Minting reverts when paused ── + let new_learner = Address::generate(env); + assert!( + credential_client + .try_mint_credential(&new_learner, &course_id, &85, &uri) + .is_err() + ); + + // ── Revoking reverts when paused ── + assert!(credential_client.try_revoke_credential(&cred_id).is_err()); + + // ── Read-only functions still work ── + let info = credential_client.verify_credential(&cred_id); + assert_eq!(info.learner, *learner); + assert_eq!(info.course_id, course_id); + assert!(!info.revoked); + + assert!(credential_client.is_credential_valid(&cred_id)); + assert_eq!(credential_client.get_credential_count(learner), 1); + + // ── Unpause ── + credential_client.unpause(); + + // ── Operations resume ── + let new_cred_id = credential_client.mint_credential(learner, &course_id, &85, &uri); + assert_eq!(new_cred_id, cred_id + 1); +} + +// ── Issue #281: Pauser role enforcement ────────────────────────────────── + +/// A Minter cannot pause, a Pauser cannot mint. +#[test] +fn test_pause_role_enforcement() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + let admin = &setup.admin; + let learner = &setup.learner; + env.mock_all_auths(); + + let token_client = LearnTokenClient::new(env, &setup.token_contract_id); + + let minter = Address::generate(env); + let pauser = Address::generate(env); + + token_client.grant_role(admin, &minter, &AdminRole::Minter); + token_client.grant_role(admin, &pauser, &AdminRole::Pauser); + + // Minter cannot pause. + assert!(token_client.try_pause(&minter).is_err()); + + // Pauser can pause and unpause. + token_client.pause(&pauser); + assert!(token_client.is_paused()); + token_client.unpause(&pauser); + + // Pauser cannot mint. + assert!(token_client.try_mint(&pauser, learner, &1000).is_err()); +} diff --git a/tests/integration/multi_admin_flow.rs b/tests/integration/multi_admin_flow.rs new file mode 100644 index 0000000..18cefdd --- /dev/null +++ b/tests/integration/multi_admin_flow.rs @@ -0,0 +1,232 @@ +//! Integration tests for multi-admin support (#279). +//! +//! Verifies that multiple admins with different roles can be added, +//! each role is enforced, revocation works, and events are emitted. + +mod fixtures; +use fixtures::setup_chainlearn_env; + +use learn_token::{AdminRole, LearnTokenClient, AdminInfo}; +use soroban_sdk::{testutils::Address as _, testutils::Events as _, Address, Symbol}; + +/// Add multiple admins with Minter, Pauser, and Admin roles. +/// Verify each role is enforced, then revoke and verify access is lost. +#[test] +fn test_multiple_admins_distinct_roles_enforced_and_revoked() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + let primary_admin = &setup.admin; + let recipient = Address::generate(env); + env.mock_all_auths(); + + let token_client = LearnTokenClient::new(env, &setup.token_contract_id); + + // ── Add multiple admins with distinct roles ── + let minter_a = Address::generate(env); + let minter_b = Address::generate(env); + let pauser = Address::generate(env); + let full_admin = Address::generate(env); + + token_client.add_admin( + primary_admin, + &AdminInfo { + address: minter_a.clone(), + role: AdminRole::Minter, + }, + ); + token_client.add_admin( + primary_admin, + &AdminInfo { + address: minter_b.clone(), + role: AdminRole::Minter, + }, + ); + token_client.add_admin( + primary_admin, + &AdminInfo { + address: pauser.clone(), + role: AdminRole::Pauser, + }, + ); + token_client.add_admin( + primary_admin, + &AdminInfo { + address: full_admin.clone(), + role: AdminRole::Admin, + }, + ); + + // Verify admin list: 5 total (primary + minter_a + minter_b + pauser + full_admin) + let admins = token_client.get_admins(); + assert_eq!(admins.len(), 5); + + // ── Verify role enforcement ── + // Both minters can mint. + token_client.mint(&minter_a, &recipient, &1000); + assert_eq!(token_client.balance(&recipient), 1000); + token_client.mint(&minter_b, &recipient, &2000); + assert_eq!(token_client.balance(&recipient), 3000); + + // Minter A cannot pause. + assert!(token_client.try_pause(&minter_a).is_err()); + // Minter B cannot pause. + assert!(token_client.try_pause(&minter_b).is_err()); + + // Pauser can pause and unpause. + token_client.pause(&pauser); + assert!(token_client.is_paused()); + token_client.unpause(&pauser); + + // Pauser cannot mint. + assert!(token_client.try_mint(&pauser, &recipient, &500).is_err()); + + // Full admin can do everything. + token_client.mint(&full_admin, &recipient, &500); + assert_eq!(token_client.balance(&recipient), 3500); + token_client.pause(&full_admin); + assert!(token_client.is_paused()); + token_client.unpause(&full_admin); + + // Full admin can also grant/revoke roles. + let temp = Address::generate(env); + token_client.grant_role(&full_admin, &temp, &AdminRole::Minter); + assert!(token_client.has_role(&temp, &AdminRole::Minter)); + token_client.revoke_role(&full_admin, &temp, &AdminRole::Minter); + assert!(!token_client.has_role(&temp, &AdminRole::Minter)); + + // ── Events emitted for role grants ── + // At least 4 role_granted events (one per add_admin above, minus the full_admin + // which also has Admin role so add_admin fires role_granted for Admin). + // The temp grant/revocation adds 2 more. + let events = env.events().all(); + let mut role_granted_count = 0u32; + let mut role_revoked_count = 0u32; + for i in 0..events.len() { + let (_, topics, _) = events.get(i).unwrap(); + let topics: soroban_sdk::Vec = topics.clone(); + if topics.len() > 0 { + let event_name: Symbol = topics.get(0).unwrap().into_val(env); + if event_name == Symbol::new(env, "role_granted") { + role_granted_count += 1; + } + if event_name == Symbol::new(env, "role_revoked") { + role_revoked_count += 1; + } + } + } + assert!(role_granted_count >= 5); // 4 add_admin + 1 grant_role + assert!(role_revoked_count >= 1); // 1 revoke_role + + // ── Revocation: remove all secondary admins ── + token_client.remove_admin( + primary_admin, + &AdminInfo { + address: minter_a.clone(), + role: AdminRole::Minter, + }, + ); + token_client.remove_admin( + primary_admin, + &AdminInfo { + address: minter_b.clone(), + role: AdminRole::Minter, + }, + ); + token_client.remove_admin( + primary_admin, + &AdminInfo { + address: pauser.clone(), + role: AdminRole::Pauser, + }, + ); + token_client.remove_admin( + primary_admin, + &AdminInfo { + address: full_admin.clone(), + role: AdminRole::Admin, + }, + ); + + // Verify revoked roles cannot perform their former actions. + assert!(!token_client.has_role(&minter_a, &AdminRole::Minter)); + assert!(!token_client.has_role(&minter_b, &AdminRole::Minter)); + assert!(!token_client.has_role(&pauser, &AdminRole::Pauser)); + assert!(!token_client.has_role(&full_admin, &AdminRole::Admin)); + + assert!(token_client.try_mint(&minter_a, &recipient, &100).is_err()); + assert!(token_client.try_mint(&minter_b, &recipient, &100).is_err()); + assert!(token_client.try_pause(&pauser).is_err()); + assert!(token_client.try_mint(&full_admin, &recipient, &100).is_err()); + + // Only primary admin remains. + let final_admins = token_client.get_admins(); + assert_eq!(final_admins.len(), 1); + assert_eq!(final_admins.get(0).unwrap().address, *primary_admin); +} + +/// Grant and revoke roles via the grant_role/revoke_role functions (not add_admin/remove_admin). +#[test] +fn test_grant_revoke_role_lifecycle() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + let admin = &setup.admin; + let recipient = Address::generate(env); + env.mock_all_auths(); + + let token_client = LearnTokenClient::new(env, &setup.token_contract_id); + + let alice = Address::generate(env); + let bob = Address::generate(env); + + // Initially no roles. + assert!(!token_client.has_role(&alice, &AdminRole::Minter)); + assert!(!token_client.has_role(&bob, &AdminRole::Pauser)); + + // Grant minter to Alice. + token_client.grant_role(admin, &alice, &AdminRole::Minter); + assert!(token_client.has_role(&alice, &AdminRole::Minter)); + assert!(!token_client.has_role(&alice, &AdminRole::Pauser)); + + // Grant pauser to Bob. + token_client.grant_role(admin, &bob, &AdminRole::Pauser); + assert!(token_client.has_role(&bob, &AdminRole::Pauser)); + assert!(!token_client.has_role(&bob, &AdminRole::Minter)); + + // Alice can mint, Bob can pause. + token_client.mint(&alice, &recipient, &500); + assert_eq!(token_client.balance(&recipient), 500); + + token_client.pause(&bob); + assert!(token_client.is_paused()); + token_client.unpause(&bob); + + // Revoke both. + token_client.revoke_role(admin, &alice, &AdminRole::Minter); + token_client.revoke_role(admin, &bob, &AdminRole::Pauser); + + // Both lose access. + assert!(!token_client.has_role(&alice, &AdminRole::Minter)); + assert!(!token_client.has_role(&bob, &AdminRole::Pauser)); + assert!(token_client.try_mint(&alice, &recipient, &100).is_err()); + assert!(token_client.try_pause(&bob).is_err()); + + // Events: role_granted x2, role_revoked x2 + let events = env.events().all(); + let mut granted = 0u32; + let mut revoked = 0u32; + for i in 0..events.len() { + let (_, topics, _) = events.get(i).unwrap(); + let topics: soroban_sdk::Vec = topics.clone(); + if topics.len() > 0 { + let event_name: Symbol = topics.get(0).unwrap().into_val(env); + if event_name == Symbol::new(env, "role_granted") { + granted += 1; + } + if event_name == Symbol::new(env, "role_revoked") { + revoked += 1; + } + } + } + assert!(granted >= 2); + assert!(revoked >= 2); +} diff --git a/tests/integration/token_vesting_flow.rs b/tests/integration/token_vesting_flow.rs new file mode 100644 index 0000000..21be6a5 --- /dev/null +++ b/tests/integration/token_vesting_flow.rs @@ -0,0 +1,166 @@ +//! Integration tests for token vesting schedules (#280). +//! +//! Verifies cliff enforcement, linear vesting, and claiming across the +//! full ChainLearn environment with all three contracts deployed. + +mod fixtures; +use fixtures::setup_chainlearn_env; + +use learn_token::LearnTokenClient; +use soroban_sdk::{testutils::Address as _, testutils::Events as _, Address, Symbol}; + +/// Create a vesting schedule, verify tokens are locked before the cliff, +/// advance time to verify linear vesting, and let the beneficiary claim. +#[test] +fn test_vesting_full_lifecycle() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + env.mock_all_auths(); + + let token_client = LearnTokenClient::new(env, &setup.token_contract_id); + let beneficiary = Address::generate(env); + + let total_amount: i128 = 10_000; + let cliff_timestamp: u64 = 100; + let duration_seconds: u64 = 1_000; + + // Set ledger timestamp to 0 so the schedule's created_at is 0. + env.ledger().with_mut(|l| { + l.timestamp = 0; + }); + + // ── Create vesting schedule ── + token_client.create_vesting(&beneficiary, &total_amount, &cliff_timestamp, &duration_seconds); + + let schedule = token_client + .get_vesting_schedule(&beneficiary) + .expect("schedule should exist"); + assert_eq!(schedule.total_amount, total_amount); + assert_eq!(schedule.cliff_timestamp, cliff_timestamp); + assert_eq!(schedule.duration_seconds, duration_seconds); + assert!(!schedule.exhausted); + assert_eq!(token_client.get_vesting_claimed(&beneficiary), 0); + assert_eq!(token_client.balance(&beneficiary), 0); + + // Events: vesting_created emitted + { + let all = env.events().all(); + let (_, topics, _) = all.last().expect("no events emitted"); + let topics: soroban_sdk::Vec = topics.clone(); + let event_name: Symbol = topics.get(0).unwrap().into_val(env); + assert_eq!(event_name, Symbol::new(env, "vesting_created")); + } + + // ── Cliff enforcement: claiming before cliff reverts ── + env.ledger().with_mut(|l| { + l.timestamp = cliff_timestamp - 1; + }); + assert!(token_client.try_claim_vested(&beneficiary).is_err()); + assert_eq!(token_client.balance(&beneficiary), 0); + + // ── Linear vesting halfway (50% vested) ── + env.ledger().with_mut(|l| { + l.timestamp = cliff_timestamp + duration_seconds / 2; + }); + + token_client.claim_vested(&beneficiary); + assert_eq!(token_client.balance(&beneficiary), 5_000); + assert_eq!(token_client.get_vesting_claimed(&beneficiary), 5_000); + assert_eq!(token_client.total_supply(), 5_000); + + let mid_schedule = token_client.get_vesting_schedule(&beneficiary).unwrap(); + assert!(!mid_schedule.exhausted); + + // Events: vesting_claimed emitted + { + let all = env.events().all(); + let (_, topics, _) = all.last().expect("no events emitted"); + let topics: soroban_sdk::Vec = topics.clone(); + let event_name: Symbol = topics.get(0).unwrap().into_val(env); + assert_eq!(event_name, Symbol::new(env, "vesting_claimed")); + } + + // ── Linear vesting complete (100% vested) ── + env.ledger().with_mut(|l| { + l.timestamp = cliff_timestamp + duration_seconds; + }); + + token_client.claim_vested(&beneficiary); + assert_eq!(token_client.balance(&beneficiary), total_amount); + assert_eq!(token_client.get_vesting_claimed(&beneficiary), total_amount); + assert_eq!(token_client.total_supply(), total_amount); + + let final_schedule = token_client.get_vesting_schedule(&beneficiary).unwrap(); + assert!(final_schedule.exhausted); + + // ── Claiming after exhaustion reverts ── + assert!(token_client.try_claim_vested(&beneficiary).is_err()); +} + +/// Cliff is strictly enforced: no tokens available before cliff_timestamp. +#[test] +fn test_vesting_cliff_strictly_enforced() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + env.mock_all_auths(); + + let token_client = LearnTokenClient::new(env, &setup.token_contract_id); + let beneficiary = Address::generate(env); + + env.ledger().with_mut(|l| { + l.timestamp = 0; + }); + + token_client.create_vesting(&beneficiary, &10_000, &500, &1_000); + + // At cliff - 1, no tokens claimable. + env.ledger().with_mut(|l| { + l.timestamp = 499; + }); + assert!(token_client.try_claim_vested(&beneficiary).is_err()); + assert_eq!(token_client.balance(&beneficiary), 0); + + // At cliff timestamp exactly, 0 tokens vested (elapsed = 0). + env.ledger().with_mut(|l| { + l.timestamp = 500; + }); + // elapsed = 0, so vested_amount = 0, claimable = 0 - 0 = 0 → "no tokens available to claim" + assert!(token_client.try_claim_vested(&beneficiary).is_err()); + + // At cliff + 1, some tokens are claimable. + env.ledger().with_mut(|l| { + l.timestamp = 501; + }); + token_client.claim_vested(&beneficiary); + assert!(token_client.balance(&beneficiary) > 0); +} + +/// Beneficiary can only claim; other addresses cannot claim on their behalf. +#[test] +fn test_vesting_beneficiary_only_claiming() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + env.mock_all_auths(); + + let token_client = LearnTokenClient::new(env, &setup.token_contract_id); + let beneficiary = Address::generate(env); + let stranger = Address::generate(env); + + env.ledger().with_mut(|l| { + l.timestamp = 0; + }); + + token_client.create_vesting(&beneficiary, &10_000, &100, &1_000); + + env.ledger().with_mut(|l| { + l.timestamp = 600; + }); + + // Stranger cannot claim on behalf of beneficiary. + assert!(token_client.try_claim_vested(&stranger).is_err()); + assert_eq!(token_client.balance(&stranger), 0); + + // Beneficiary can claim. + token_client.claim_vested(&beneficiary); + assert!(token_client.balance(&beneficiary) > 0); +}