From 7eda45290a4d3ee5e715313615c8c0024564ebfa Mon Sep 17 00:00:00 2001 From: harystyleseze Date: Sun, 30 Aug 2026 03:09:38 -0700 Subject: [PATCH] test: add coverage for admin roles, batch ops, allowance cleanup, errors Adds integration tests for admin role separation (minter vs pauser) and batch-style claim/module/quiz flows, plus unit tests for expired allowance cleanup and typed AlreadyInitialized error handling across all three contracts. Re-exports AdminRole from learn-token so tests can construct role values, following the existing Proposal/VestingSchedule export pattern. --- Cargo.toml | 8 ++ contracts/learn-token/src/lib.rs | 2 +- tests/integration/admin_role_flow.rs | 104 +++++++++++++++++++ tests/integration/batch_operations.rs | 141 ++++++++++++++++++++++++++ tests/unit/credential_tests.rs | 19 ++++ tests/unit/progress_tests.rs | 19 ++++ tests/unit/token_tests.rs | 58 +++++++++++ 7 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 tests/integration/admin_role_flow.rs create mode 100644 tests/integration/batch_operations.rs diff --git a/Cargo.toml b/Cargo.toml index 6cb560d..2441c6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,14 @@ path = "tests/integration/credential_flow.rs" name = "full_flow" path = "tests/integration/full_flow.rs" +[[test]] +name = "admin_role_flow" +path = "tests/integration/admin_role_flow.rs" + +[[test]] +name = "batch_operations" +path = "tests/integration/batch_operations.rs" + [profile.release] opt-level = "z" overflow-checks = true diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index cfe178b..b759039 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -10,7 +10,7 @@ use soroban_sdk::{ }; // Re-export governance/vesting types so tests can use them. -pub use storage::{Proposal, VestingSchedule}; +pub use storage::{AdminRole, Proposal, VestingSchedule}; /// Maximum reward tokens that can be minted in a single claim (#78). /// Caps at MAX_QUIZ_SCORE * BASE_REWARD_PER_POINT (100 * 100 = 10_000). diff --git a/tests/integration/admin_role_flow.rs b/tests/integration/admin_role_flow.rs new file mode 100644 index 0000000..fbbe03e --- /dev/null +++ b/tests/integration/admin_role_flow.rs @@ -0,0 +1,104 @@ +//! Integration tests for admin role separation on the learn-token contract. +//! +//! Verifies that the Minter and Pauser roles are enforced independently: a +//! minter can mint but cannot pause, a pauser can pause but cannot mint, +//! role changes take effect immediately, role revocation removes access, and +//! role changes emit events. + +mod fixtures; +use fixtures::setup_chainlearn_env; + +use learn_token::{AdminRole, LearnTokenClient}; +use soroban_sdk::{testutils::Address as _, testutils::Events as _, Address, IntoVal, Symbol}; + +#[test] +fn test_admin_role_separation() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + let admin = &setup.admin; + env.mock_all_auths(); + + let token_client = LearnTokenClient::new(env, &setup.token_contract_id); + + let minter = Address::generate(env); + let pauser = Address::generate(env); + let recipient = Address::generate(env); + + // Neither address starts with any role. + assert!(!token_client.has_role(&minter, &AdminRole::Minter)); + assert!(!token_client.has_role(&pauser, &AdminRole::Pauser)); + + // Grant the minter role. + token_client.grant_role(admin, &minter, &AdminRole::Minter); + assert!(token_client.has_role(&minter, &AdminRole::Minter)); + assert!(!token_client.has_role(&minter, &AdminRole::Pauser)); + { + 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); + let address_topic: Address = topics.get(1).unwrap().into_val(env); + assert_eq!(event_name, Symbol::new(env, "role_granted")); + assert_eq!(address_topic, minter); + } + + // The minter can mint... + token_client.mint(&minter, &recipient, &1000); + assert_eq!(token_client.balance(&recipient), 1000); + + // ...but cannot pause the contract. + assert!(token_client.try_pause(&minter).is_err()); + + // Grant the pauser role. + token_client.grant_role(admin, &pauser, &AdminRole::Pauser); + assert!(token_client.has_role(&pauser, &AdminRole::Pauser)); + assert!(!token_client.has_role(&pauser, &AdminRole::Minter)); + { + 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); + let address_topic: Address = topics.get(1).unwrap().into_val(env); + assert_eq!(event_name, Symbol::new(env, "role_granted")); + assert_eq!(address_topic, pauser); + } + + // The pauser can pause and unpause... + token_client.pause(&pauser); + assert!(token_client.is_paused()); + token_client.unpause(&pauser); + assert!(!token_client.is_paused()); + + // ...but cannot mint. + assert!(token_client.try_mint(&pauser, &recipient, &1000).is_err()); + + // Revoke both roles. + token_client.revoke_role(admin, &minter, &AdminRole::Minter); + { + 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); + let address_topic: Address = topics.get(1).unwrap().into_val(env); + assert_eq!(event_name, Symbol::new(env, "role_revoked")); + assert_eq!(address_topic, minter); + } + + token_client.revoke_role(admin, &pauser, &AdminRole::Pauser); + { + 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); + let address_topic: Address = topics.get(1).unwrap().into_val(env); + assert_eq!(event_name, Symbol::new(env, "role_revoked")); + assert_eq!(address_topic, pauser); + } + + // Revocation removes access: neither address can perform its former + // action anymore. + assert!(!token_client.has_role(&minter, &AdminRole::Minter)); + assert!(!token_client.has_role(&pauser, &AdminRole::Pauser)); + assert!(token_client.try_mint(&minter, &recipient, &1000).is_err()); + assert!(token_client.try_pause(&pauser).is_err()); +} diff --git a/tests/integration/batch_operations.rs b/tests/integration/batch_operations.rs new file mode 100644 index 0000000..5e69da0 --- /dev/null +++ b/tests/integration/batch_operations.rs @@ -0,0 +1,141 @@ +//! Integration tests for batch operations across the ChainLearn contracts. +//! +//! Covers `learn_token::batch_claim_reward` (a true batch entry point) plus +//! batch-style flows built from the progress-tracker's per-module and +//! per-quiz functions: completing several modules and submitting several +//! quiz scores in one learner session. In every case each operation is +//! verified to succeed (or be skipped) independently of the others. + +mod fixtures; +use fixtures::setup_chainlearn_env; + +use learn_token::LearnTokenClient; +use progress_tracker::ProgressTrackerClient; +use soroban_sdk::{Symbol, Vec}; + +#[test] +fn test_batch_claim_reward_processes_quizzes_independently() { + let setup = setup_chainlearn_env(); + let env = &setup.env; + 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); + + let course_id = Symbol::new(env, "course_batch"); + let quiz1 = Symbol::new(env, "quiz_1"); + let quiz2 = Symbol::new(env, "quiz_2"); + let quiz3 = Symbol::new(env, "quiz_3"); + + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(quiz1.clone()); + quiz_ids.push_back(quiz2.clone()); + quiz_ids.push_back(quiz3.clone()); + + progress_client.create_course(&course_id, &1, &3, &module_ids, &quiz_ids); + progress_client.enroll(learner, &course_id); + progress_client.submit_quiz_score(learner, &course_id, &quiz1, &80); + progress_client.submit_quiz_score(learner, &course_id, &quiz2, &60); + progress_client.submit_quiz_score(learner, &course_id, &quiz3, &90); + + // Claim quiz2's reward individually up front so the batch call below + // has to skip an already-claimed quiz alongside two unclaimed ones -- + // proving a partial failure doesn't block the other claims in the batch. + token_client.claim_reward(learner, &course_id, &quiz2); + assert_eq!(token_client.balance(learner), 6000); + + let mut batch_ids = Vec::new(env); + batch_ids.push_back(quiz1.clone()); + batch_ids.push_back(quiz2.clone()); + batch_ids.push_back(quiz3.clone()); + + let claimed = token_client.batch_claim_reward(learner, &course_id, &batch_ids); + + // Only the two not-yet-claimed quizzes succeed in the batch. + assert_eq!(claimed.len(), 2); + assert!(claimed.contains(quiz1.clone())); + assert!(claimed.contains(quiz3.clone())); + assert!(!claimed.contains(quiz2.clone())); + + // 80*100 + 60*100 (individual) + 90*100 = 8000 + 6000 + 9000 = 23000 + assert_eq!(token_client.balance(learner), 23000); + assert_eq!(token_client.total_supply(), 23000); + + // Reclaiming the same batch again succeeds the call but yields nothing, + // since every quiz in it is now already claimed. + let reclaimed = token_client.batch_claim_reward(learner, &course_id, &batch_ids); + assert_eq!(reclaimed.len(), 0); + assert_eq!(token_client.balance(learner), 23000); +} + +#[test] +fn test_batch_module_completion() { + 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); + + let course_id = Symbol::new(env, "course_modules"); + 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")); + module_ids.push_back(Symbol::new(env, "mod_3")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(Symbol::new(env, "quiz_1")); + + progress_client.create_course(&course_id, &3, &1, &module_ids, &quiz_ids); + progress_client.enroll(learner, &course_id); + + // Batch-complete every module in the course in a single learner session. + for module_id in module_ids.iter() { + progress_client.complete_module(learner, &course_id, &module_id); + } + + let progress = progress_client.get_progress(learner, &course_id); + assert_eq!(progress.modules_completed_bitmap.count_ones(), 3); +} + +#[test] +fn test_batch_quiz_submission() { + 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); + + let course_id = Symbol::new(env, "course_quizzes"); + let mut module_ids = Vec::new(env); + module_ids.push_back(Symbol::new(env, "mod_1")); + let mut quiz_ids = Vec::new(env); + quiz_ids.push_back(Symbol::new(env, "quiz_1")); + quiz_ids.push_back(Symbol::new(env, "quiz_2")); + quiz_ids.push_back(Symbol::new(env, "quiz_3")); + + progress_client.create_course(&course_id, &1, &3, &module_ids, &quiz_ids); + progress_client.enroll(learner, &course_id); + + let scores = [70u32, 85u32, 95u32]; + + // Batch-submit every quiz score for the course in a single learner + // session; each submission succeeds independently of the others. + for (i, quiz_id) in quiz_ids.iter().enumerate() { + progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &scores[i]); + } + + for (i, quiz_id) in quiz_ids.iter().enumerate() { + assert_eq!( + progress_client.get_quiz_score(learner, &course_id, &quiz_id), + scores[i] + ); + } + + let progress = progress_client.get_progress(learner, &course_id); + assert_eq!(progress.quizzes_submitted, 3); + assert_eq!(progress.total_quiz_score, 70 + 85 + 95); +} diff --git a/tests/unit/credential_tests.rs b/tests/unit/credential_tests.rs index d244230..53ab686 100644 --- a/tests/unit/credential_tests.rs +++ b/tests/unit/credential_tests.rs @@ -241,4 +241,23 @@ mod credential_unit_tests { // We do not mock auths, so revoke_credential must fail admin auth check client.revoke_credential(&1); } + + #[test] + fn test_initialize_twice_returns_already_initialized_error() { + let env = Env::default(); + let (admin, contract_id, tracker_id) = setup_contract(&env); + let client = CredentialNftClient::new(&env, &contract_id); + + let result = client.try_initialize(&admin, &tracker_id); + + assert!(result.is_err(), "second initialize call should fail"); + let contract_err = result + .err() + .expect("expected an error") + .expect("expected a typed contract error, not a host trap"); + assert_eq!( + contract_err, + credential_nft::ContractError::AlreadyInitialized + ); + } } diff --git a/tests/unit/progress_tests.rs b/tests/unit/progress_tests.rs index 364a0ab..b74b65d 100644 --- a/tests/unit/progress_tests.rs +++ b/tests/unit/progress_tests.rs @@ -728,4 +728,23 @@ mod progress_unit_tests { // quiz_1 is in course_id, not in other_course_id client.get_quiz_score(&learner, &other_course_id, &Symbol::new(&env, "quiz_1")); } + + #[test] + fn test_initialize_twice_returns_already_initialized_error() { + let env = Env::default(); + let (admin, contract_id) = setup_contract(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + + let result = client.try_initialize(&admin); + + assert!(result.is_err(), "second initialize call should fail"); + let contract_err = result + .err() + .expect("expected an error") + .expect("expected a typed contract error, not a host trap"); + assert_eq!( + contract_err, + progress_tracker::ContractError::AlreadyInitialized + ); + } } diff --git a/tests/unit/token_tests.rs b/tests/unit/token_tests.rs index cf9b160..9c3c7ff 100644 --- a/tests/unit/token_tests.rs +++ b/tests/unit/token_tests.rs @@ -474,4 +474,62 @@ mod token_unit_tests { assert_eq!(learner_topic, learner); assert_eq!(course_topic, course_id); } + + #[test] + fn test_cleanup_expired_allowances_removes_only_expired() { + let env = Env::default(); + let (_admin, contract_id, _) = setup_token(&env); + let client = LearnTokenClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let spender_expiring = Address::generate(&env); + let spender_valid = Address::generate(&env); + env.mock_all_auths(); + + client.approve(&owner, &spender_expiring, &100, &10); + client.approve(&owner, &spender_valid, &200, &999999); + + assert_eq!(client.allowance_spender_count(&owner), 2); + + env.ledger().with_mut(|l| { + l.sequence_number = 20; + }); + + let removed = client.cleanup_expired_allowances(&owner); + + // Only the expired allowance is removed; the valid one is preserved + // and storage (the spender registry) shrinks accordingly. + assert_eq!(removed, 1); + assert_eq!(client.allowance_spender_count(&owner), 1); + assert_eq!(client.allowance(&owner, &spender_valid), 200); + assert_eq!(client.allowance(&owner, &spender_expiring), 0); + + // No side effects: cleaning up again finds nothing left to remove. + let removed_again = client.cleanup_expired_allowances(&owner); + assert_eq!(removed_again, 0); + assert_eq!(client.allowance_spender_count(&owner), 1); + } + + #[test] + fn test_initialize_twice_returns_already_initialized_error() { + let env = Env::default(); + let (admin, contract_id, pt_contract_id) = setup_token(&env); + let client = LearnTokenClient::new(&env, &contract_id); + + let result = client.try_initialize( + &admin, + &SorobanString::from_str(&env, "CLearn"), + &SorobanString::from_str(&env, "CLRN"), + &7, + &pt_contract_id, + &1_000_000_000_000_000, + ); + + assert!(result.is_err(), "second initialize call should fail"); + let contract_err = result + .err() + .expect("expected an error") + .expect("expected a typed contract error, not a host trap"); + assert_eq!(contract_err, learn_token::ContractError::AlreadyInitialized); + } }