From b65ace86ff519865b101a6743f10277c47da5328 Mon Sep 17 00:00:00 2001 From: cyberBlud Date: Tue, 20 Oct 2026 09:12:55 -0700 Subject: [PATCH 1/4] feat(token): Fix nested test functions in token_tests.rs (#581) Keep the three governance pause-path tests at module scope so the test harness registers them. They were already moved out of the enclosing tests in e31f449; this adds a regression note and leaves their behavior unchanged. --- tests/unit/token_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/token_tests.rs b/tests/unit/token_tests.rs index e5f3a53..ff00774 100644 --- a/tests/unit/token_tests.rs +++ b/tests/unit/token_tests.rs @@ -931,6 +931,9 @@ mod token_unit_tests { ); } + // Regression guard (#581): these three pause-path tests must stay at + // module scope so the Rust test harness registers each as an independent + // test. Keep them out of any other test function's body. #[test] #[should_panic(expected = "contract is paused")] fn test_create_proposal_fails_while_paused() { From 97297275f445b54df680004f1a58aed8a143f7fc Mon Sep 17 00:00:00 2001 From: cyberBlud Date: Tue, 20 Oct 2026 09:13:16 -0700 Subject: [PATCH 2/4] feat(integration): Add integration test for negative flows in full learner journey (#580) Cover early reward claims, credential minting before eligibility, below-threshold scores, and the paused/unpaused lifecycle across all three contracts. --- tests/integration/full_flow.rs | 189 ++++++++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/tests/integration/full_flow.rs b/tests/integration/full_flow.rs index 95d7bdd..1e13ecb 100644 --- a/tests/integration/full_flow.rs +++ b/tests/integration/full_flow.rs @@ -6,7 +6,7 @@ use fixtures::{setup_chainlearn_env, create_sample_course, complete_full_course} use learn_token::LearnTokenClient; use credential_nft::CredentialNftClient; use progress_tracker::ProgressTrackerClient; -use soroban_sdk::Symbol; +use soroban_sdk::{testutils::Address as _, Address, Symbol}; #[test] fn test_full_learner_journey() { @@ -54,3 +54,190 @@ fn test_full_learner_journey() { assert_eq!(info.score, 80); assert!(!info.revoked); } + +#[test] +fn test_claim_reward_before_quiz_submission_fails() { + let env_context = setup_chainlearn_env(); + let env = &env_context.env; + let learner = &env_context.learner; + + let token_client = LearnTokenClient::new(env, &env_context.token_contract_id); + let progress_client = ProgressTrackerClient::new(env, &env_context.progress_contract_id); + + env.mock_all_auths(); + + let course_id = create_sample_course(env, &progress_client); + progress_client.enroll(learner, &course_id); + + // No quiz score has been submitted yet, so the tracker cannot verify a + // score and the reward claim must fail without minting anything. + let result = token_client.try_claim_reward( + learner, + &course_id, + &Symbol::new(env, "quiz_midterm"), + ); + assert!(result.is_err()); + assert_eq!(token_client.balance(learner), 0); + assert_eq!(token_client.total_supply(), 0); +} + +#[test] +fn test_mint_credential_before_completing_all_modules_fails() { + let env_context = setup_chainlearn_env(); + let env = &env_context.env; + let learner = &env_context.learner; + + let credential_client = CredentialNftClient::new(env, &env_context.credential_contract_id); + let progress_client = ProgressTrackerClient::new(env, &env_context.progress_contract_id); + + env.mock_all_auths(); + + let course_id = create_sample_course(env, &progress_client); + progress_client.enroll(learner, &course_id); + + // Finish both quizzes but leave every module incomplete. + progress_client.submit_quiz_score(learner, &course_id, &Symbol::new(env, "quiz_midterm"), &85); + progress_client.submit_quiz_score(learner, &course_id, &Symbol::new(env, "quiz_final"), &75); + + assert!(!progress_client.is_eligible_for_credential(learner, &course_id)); + + let result = credential_client.try_mint_credential( + learner, + &course_id, + &80, + &Symbol::new(env, "ipfs_QmCredential123"), + ); + assert!(result.is_err()); + assert_eq!(credential_client.get_credential_count(learner), 0); +} + +#[test] +fn test_mint_credential_with_score_below_minimum_fails() { + let env_context = setup_chainlearn_env(); + let env = &env_context.env; + let learner = &env_context.learner; + + let credential_client = CredentialNftClient::new(env, &env_context.credential_contract_id); + let progress_client = ProgressTrackerClient::new(env, &env_context.progress_contract_id); + + env.mock_all_auths(); + + let course_id = create_sample_course(env, &progress_client); + progress_client.enroll(learner, &course_id); + progress_client.complete_module(learner, &course_id, &Symbol::new(env, "mod_basics")); + progress_client.complete_module(learner, &course_id, &Symbol::new(env, "mod_ownership")); + progress_client.complete_module(learner, &course_id, &Symbol::new(env, "mod_traits")); + + // Average of 40 is below the 50-point credential threshold, so the + // learner never becomes eligible. + progress_client.submit_quiz_score(learner, &course_id, &Symbol::new(env, "quiz_midterm"), &40); + progress_client.submit_quiz_score(learner, &course_id, &Symbol::new(env, "quiz_final"), &40); + assert!(!progress_client.is_eligible_for_credential(learner, &course_id)); + + let result = credential_client.try_mint_credential( + learner, + &course_id, + &40, + &Symbol::new(env, "ipfs_QmCredential123"), + ); + assert!(result.is_err()); + assert_eq!(credential_client.get_credential_count(learner), 0); +} + +#[test] +fn test_operations_blocked_during_pause() { + let env_context = setup_chainlearn_env(); + let env = &env_context.env; + let admin = &env_context.admin; + let learner = &env_context.learner; + + let token_client = LearnTokenClient::new(env, &env_context.token_contract_id); + let credential_client = CredentialNftClient::new(env, &env_context.credential_contract_id); + let progress_client = ProgressTrackerClient::new(env, &env_context.progress_contract_id); + + env.mock_all_auths(); + + let course_id = create_sample_course(env, &progress_client); + complete_full_course(env, learner, &course_id, &progress_client); + + // Claim one reward before pausing so transfer and burn have a balance to + // operate on; leave the final quiz unclaimed for the paused claim check. + token_client.claim_reward(learner, &course_id, &Symbol::new(env, "quiz_midterm")); + assert_eq!(token_client.balance(learner), 8500); + + token_client.pause(admin); + progress_client.emergency_pause(); + credential_client.emergency_pause(); + + // Token operations are rejected while paused. + let recipient = Address::generate(env); + assert!(token_client.try_transfer(learner, &recipient, &1_000).is_err()); + assert!(token_client.try_burn(learner, &1_000).is_err()); + assert!(token_client + .try_claim_reward(learner, &course_id, &Symbol::new(env, "quiz_final")) + .is_err()); + + // Progress-tracker writes are rejected while paused. + assert!(progress_client + .try_retake_quiz(learner, &course_id, &Symbol::new(env, "quiz_midterm"), &90) + .is_err()); + + // Credential minting is rejected while paused even though the learner is + // otherwise fully eligible. + assert!(credential_client + .try_mint_credential( + learner, + &course_id, + &80, + &Symbol::new(env, "ipfs_QmCredential123"), + ) + .is_err()); + + // No paused call changed state. + assert_eq!(token_client.balance(learner), 8500); + assert_eq!(token_client.total_supply(), 8500); + assert_eq!(token_client.balance(&recipient), 0); + assert_eq!(credential_client.get_credential_count(learner), 0); +} + +#[test] +fn test_operations_restored_after_unpause() { + let env_context = setup_chainlearn_env(); + let env = &env_context.env; + let admin = &env_context.admin; + let learner = &env_context.learner; + + let token_client = LearnTokenClient::new(env, &env_context.token_contract_id); + let credential_client = CredentialNftClient::new(env, &env_context.credential_contract_id); + let progress_client = ProgressTrackerClient::new(env, &env_context.progress_contract_id); + + env.mock_all_auths(); + + let course_id = create_sample_course(env, &progress_client); + + token_client.pause(admin); + progress_client.emergency_pause(); + credential_client.emergency_pause(); + + token_client.unpause(admin); + progress_client.unpause(); + credential_client.unpause(); + + // The full learner journey works again once every contract is unpaused. + complete_full_course(env, learner, &course_id, &progress_client); + + token_client.claim_reward(learner, &course_id, &Symbol::new(env, "quiz_midterm")); + token_client.claim_reward(learner, &course_id, &Symbol::new(env, "quiz_final")); + assert_eq!(token_client.balance(learner), 16000); + + let cred_id = credential_client.mint_credential( + learner, + &course_id, + &80, + &Symbol::new(env, "ipfs_QmCredential123"), + ); + let info = credential_client.verify_credential(&cred_id); + assert_eq!(info.learner, *learner); + assert_eq!(info.score, 80); + assert_eq!(credential_client.get_credential_count(learner), 1); +} From 9ce0674ee2bfab4a060a04e11e81c87c8853157d Mon Sep 17 00:00:00 2001 From: cyberBlud Date: Tue, 20 Oct 2026 09:13:32 -0700 Subject: [PATCH 3/4] feat(integration): Add missing integration test for transfer_from with allowance (#579) Cover transfer_from after approve, allowance deduction across partial spends, and rejection of expired allowances. --- tests/integration/token_flow.rs | 95 ++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/tests/integration/token_flow.rs b/tests/integration/token_flow.rs index 58a59c8..613f504 100644 --- a/tests/integration/token_flow.rs +++ b/tests/integration/token_flow.rs @@ -6,7 +6,10 @@ mod fixtures; use learn_token::LearnTokenClient; use progress_tracker::ProgressTrackerClient; -use soroban_sdk::{testutils::Address as _, Address, Env, Symbol, Vec}; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Address, Env, Symbol, Vec, +}; /// Create a minimal course (one module, just the given quiz ids) and enroll /// `learner` in it. `claim_reward` only requires enrollment plus a submitted @@ -151,3 +154,93 @@ fn test_total_supply_consistency() { assert_eq!(token_client.total_supply(), 13000); } + +#[test] +fn test_transfer_from_after_approve() { + let setup = fixtures::setup_chainlearn_env(); + let env = &setup.env; + let learner = &setup.learner; + let spender = Address::generate(env); + let recipient = Address::generate(env); + 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_1"); + let quiz_id = Symbol::new(env, "quiz_1"); + setup_course_and_enroll(env, &progress_client, learner, &course_id, &[quiz_id.clone()]); + progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &80); + token_client.claim_reward(learner, &course_id, &quiz_id); // 8000 + + token_client.approve(learner, &spender, &5_000, &999_999); + + // The approved spender moves tokens on the learner's behalf. + token_client.transfer_from(&spender, learner, &recipient, &3_000); + + assert_eq!(token_client.balance(learner), 5_000); + assert_eq!(token_client.balance(&recipient), 3_000); + assert_eq!(token_client.allowance(learner, &spender), 2_000); + assert_eq!(token_client.total_supply(), 8_000); +} + +#[test] +fn test_transfer_from_deducts_allowance_correctly() { + let setup = fixtures::setup_chainlearn_env(); + let env = &setup.env; + let learner = &setup.learner; + let spender = Address::generate(env); + let recipient = Address::generate(env); + 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_1"); + let quiz_id = Symbol::new(env, "quiz_1"); + setup_course_and_enroll(env, &progress_client, learner, &course_id, &[quiz_id.clone()]); + progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &100); + token_client.claim_reward(learner, &course_id, &quiz_id); // 10000 + + token_client.approve(learner, &spender, &6_000, &999_999); + + // Each spend reduces the remaining allowance by exactly its amount. + token_client.transfer_from(&spender, learner, &recipient, &2_000); + assert_eq!(token_client.allowance(learner, &spender), 4_000); + + token_client.transfer_from(&spender, learner, &recipient, &4_000); + assert_eq!(token_client.allowance(learner, &spender), 0); + assert_eq!(token_client.balance(learner), 4_000); + assert_eq!(token_client.balance(&recipient), 6_000); +} + +#[test] +fn test_transfer_from_with_expired_allowance_fails() { + let setup = fixtures::setup_chainlearn_env(); + let env = &setup.env; + let learner = &setup.learner; + let spender = Address::generate(env); + let recipient = Address::generate(env); + 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_1"); + let quiz_id = Symbol::new(env, "quiz_1"); + setup_course_and_enroll(env, &progress_client, learner, &course_id, &[quiz_id.clone()]); + progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &80); + token_client.claim_reward(learner, &course_id, &quiz_id); // 8000 + + token_client.approve(learner, &spender, &2_000, &10); + env.ledger().with_mut(|ledger| ledger.sequence_number = 20); + + // Reading the expired allowance returns zero rather than the stale value. + assert_eq!(token_client.allowance(learner, &spender), 0); + + let result = token_client.try_transfer_from(&spender, learner, &recipient, &1_000); + assert!(result.is_err()); + assert_eq!(token_client.balance(learner), 8_000); + assert_eq!(token_client.balance(&recipient), 0); + assert_eq!(token_client.total_supply(), 8_000); +} From 974b5ca91abe3baa8e8ef9650da2206d27c4e10a Mon Sep 17 00:00:00 2001 From: cyberBlud Date: Tue, 20 Oct 2026 09:13:44 -0700 Subject: [PATCH 4/4] feat(integration): Add missing integration test for burning tokens (#578) Cover burn reducing balance and total supply, burn_from drawing down an allowance, and burning after a full earn-and-claim learner journey. --- tests/integration/token_flow.rs | 75 +++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/integration/token_flow.rs b/tests/integration/token_flow.rs index 613f504..722cde3 100644 --- a/tests/integration/token_flow.rs +++ b/tests/integration/token_flow.rs @@ -244,3 +244,78 @@ fn test_transfer_from_with_expired_allowance_fails() { assert_eq!(token_client.balance(&recipient), 0); assert_eq!(token_client.total_supply(), 8_000); } + +#[test] +fn test_burn_reduces_balance_and_total_supply() { + let setup = fixtures::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_1"); + let quiz_id = Symbol::new(env, "quiz_1"); + setup_course_and_enroll(env, &progress_client, learner, &course_id, &[quiz_id.clone()]); + progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &80); + token_client.claim_reward(learner, &course_id, &quiz_id); // 8000 + + token_client.burn(learner, &3_000); + + assert_eq!(token_client.balance(learner), 5_000); + assert_eq!(token_client.total_supply(), 5_000); +} + +#[test] +fn test_burn_from_with_allowance() { + let setup = fixtures::setup_chainlearn_env(); + let env = &setup.env; + let learner = &setup.learner; + let spender = Address::generate(env); + 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_1"); + let quiz_id = Symbol::new(env, "quiz_1"); + setup_course_and_enroll(env, &progress_client, learner, &course_id, &[quiz_id.clone()]); + progress_client.submit_quiz_score(learner, &course_id, &quiz_id, &80); + token_client.claim_reward(learner, &course_id, &quiz_id); // 8000 + + token_client.approve(learner, &spender, &5_000, &999_999); + + // The spender destroys part of the owner's balance using the allowance. + token_client.burn_from(&spender, learner, &2_000); + + assert_eq!(token_client.balance(learner), 6_000); + assert_eq!(token_client.allowance(learner, &spender), 3_000); + assert_eq!(token_client.total_supply(), 6_000); + assert_eq!(token_client.balance(&spender), 0); +} + +#[test] +fn test_burn_after_full_learner_journey() { + let setup = fixtures::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 = fixtures::create_sample_course(env, &progress_client); + fixtures::complete_full_course(env, learner, &course_id, &progress_client); + + // Earn the rewards for both quizzes, then burn part of the balance. + token_client.claim_reward(learner, &course_id, &Symbol::new(env, "quiz_midterm")); + token_client.claim_reward(learner, &course_id, &Symbol::new(env, "quiz_final")); + assert_eq!(token_client.balance(learner), 16_000); + assert_eq!(token_client.total_supply(), 16_000); + + token_client.burn(learner, &6_000); + + assert_eq!(token_client.balance(learner), 10_000); + assert_eq!(token_client.total_supply(), 10_000); +}