Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 337 additions & 1 deletion tests/unit/xcontract_call_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! quiz scores, handles various response scenarios, and maintains consistent
//! state when cross-contract calls fail.

use learn_token::LearnTokenClient;
use learn_token::{AdminRole, LearnTokenClient};
use progress_tracker::{ProgressTracker, ProgressTrackerClient};
use soroban_sdk::{
testutils::Address as _, Address, Env, String as SorobanString, Symbol, Vec,
Expand Down Expand Up @@ -271,6 +271,342 @@ mod xcontract_call_tests {

// ── Multiple learners, same course ───────────────────────────────────

// ── Zero-score cross-contract response ──────────────────────────────

/// A score of 0 returned by the progress-tracker is not a valid reward
/// basis. learn-token must reject it with "score must be greater than 0"
/// rather than silently minting nothing or panicking with an unrelated
/// message. This exercises the path where the cross-contract call itself
/// succeeds (score is fetched) but the value is semantically invalid.
///
/// We drive score 0 by enrolling the learner but then using `retake_quiz`
/// to reset the score back to 0 after submission. The cross-contract
/// call inside `claim_reward` will read 0 and must panic.
#[test]
#[should_panic(expected = "score must be greater than 0")]
fn test_claim_reward_rejects_zero_score_from_cross_contract() {
let env = Env::default();
let (_admin, token_id, pt_id) = setup(&env);
let token_client = LearnTokenClient::new(&env, &token_id);
let pt_client = ProgressTrackerClient::new(&env, &pt_id);

let learner = Address::generate(&env);
env.mock_all_auths();

let course_id = Symbol::new(&env, "course_1");
let quiz_id = Symbol::new(&env, "quiz_1");

// Create course and submit a non-zero score first so we can enroll,
// then reset via retake_quiz which sets the score back to 0.
create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 75);
// retake_quiz overwrites the stored score to 0 so the next cross-contract
// call from learn-token will read back 0.
pt_client.retake_quiz(&learner, &course_id, &quiz_id, &0u32);

// The cross-contract call returns 0; claim_reward must panic.
token_client.claim_reward(&learner, &course_id, &quiz_id);
}

/// After a zero-score rejection the token state (balance, supply) must be
/// completely unchanged — the failed cross-contract read must not leave
/// any partial side-effects.
#[test]
fn test_zero_score_rejection_does_not_corrupt_token_state() {
let env = Env::default();
let (_admin, token_id, pt_id) = setup(&env);
let token_client = LearnTokenClient::new(&env, &token_id);
let pt_client = ProgressTrackerClient::new(&env, &pt_id);

let learner = Address::generate(&env);
env.mock_all_auths();

let course_id = Symbol::new(&env, "course_1");
let quiz_id = Symbol::new(&env, "quiz_1");

create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 75);
pt_client.retake_quiz(&learner, &course_id, &quiz_id, &0u32);

let supply_before = token_client.total_supply();
let balance_before = token_client.balance(&learner);

let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
token_client.claim_reward(&learner, &course_id, &quiz_id);
}));
assert!(result.is_err(), "zero-score claim must revert");

assert_eq!(token_client.total_supply(), supply_before);
assert_eq!(token_client.balance(&learner), balance_before);
}

// ── estimate_claim_gas: cross-contract dry-run ───────────────────────

/// `estimate_claim_gas` must perform the same cross-contract call as
/// `claim_reward` (fetching the score from progress-tracker) and report
/// `would_succeed = true` with the correct reward amount when everything
/// is in order. Crucially, it must NOT change any state.
#[test]
fn test_estimate_claim_gas_cross_contract_preview_success() {
let env = Env::default();
let (_admin, token_id, pt_id) = setup(&env);
let token_client = LearnTokenClient::new(&env, &token_id);
let pt_client = ProgressTrackerClient::new(&env, &pt_id);

let learner = Address::generate(&env);
env.mock_all_auths();

let course_id = Symbol::new(&env, "course_1");
let quiz_id = Symbol::new(&env, "quiz_1");
create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 70);

let supply_before = token_client.total_supply();
let balance_before = token_client.balance(&learner);

// estimate_claim_gas crosses into progress-tracker to verify the score
// (score 70 * 100 = 7000) but must never mint or modify state.
let estimate = token_client.estimate_claim_gas(&learner, &course_id, &quiz_id);

assert!(estimate.would_succeed, "preview must report success");
assert_eq!(
estimate.estimated_reward, 7000,
"estimated reward must match score * BASE_REWARD_PER_POINT"
);

// Verify no state was mutated.
assert_eq!(token_client.total_supply(), supply_before);
assert_eq!(token_client.balance(&learner), balance_before);
}

/// `estimate_claim_gas` must detect an already-claimed reward without
/// performing the cross-contract score fetch and report failure cleanly.
#[test]
fn test_estimate_claim_gas_reports_failure_for_already_claimed() {
let env = Env::default();
let (_admin, token_id, pt_id) = setup(&env);
let token_client = LearnTokenClient::new(&env, &token_id);
let pt_client = ProgressTrackerClient::new(&env, &pt_id);

let learner = Address::generate(&env);
env.mock_all_auths();

let course_id = Symbol::new(&env, "course_1");
let quiz_id = Symbol::new(&env, "quiz_1");
create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 60);

// First claim succeeds.
token_client.claim_reward(&learner, &course_id, &quiz_id);
assert_eq!(token_client.balance(&learner), 6000);

// Preview after a successful claim must report failure, not panic.
let estimate = token_client.estimate_claim_gas(&learner, &course_id, &quiz_id);
assert!(
!estimate.would_succeed,
"preview must report failure for already-claimed reward"
);
}

/// `estimate_claim_gas` must report failure when the cross-contract score
/// fetch itself would fail (quiz not submitted), propagating the error
/// cleanly rather than panicking.
#[test]
#[should_panic(expected = "quiz not submitted")]
fn test_estimate_claim_gas_propagates_cross_contract_failure() {
let env = Env::default();
let (_admin, token_id, pt_id) = setup(&env);
let token_client = LearnTokenClient::new(&env, &token_id);
let pt_client = ProgressTrackerClient::new(&env, &pt_id);

let learner = Address::generate(&env);
env.mock_all_auths();

let course_id = Symbol::new(&env, "course_1");
let quiz_id = Symbol::new(&env, "quiz_1");
// Create course and enroll but do NOT submit a score.
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(quiz_id.clone());
pt_client.create_course(&course_id, &1, &1, &module_ids, &quiz_ids);
pt_client.enroll(&learner, &course_id);

// estimate_claim_gas calls get_quiz_score cross-contract;
// progress-tracker panics "quiz not submitted" — that must propagate.
token_client.estimate_claim_gas(&learner, &course_id, &quiz_id);
}

// ── Supply cap after successful cross-contract fetch ─────────────────

/// When the cross-contract score fetch succeeds but the resulting reward
/// would push total supply past the cap, `claim_reward` must panic with
/// "maximum supply cap exceeded". Token balance and supply must remain
/// exactly as before — the cross-contract read must not leave any write
/// side-effects.
#[test]
fn test_supply_cap_hit_after_cross_contract_score_fetch_no_state_corruption() {
let env = Env::default();
let admin = Address::generate(&env);

let pt_contract_id = env.register_contract(None, ProgressTracker);
let pt_client = ProgressTrackerClient::new(&env, &pt_contract_id);
pt_client.initialize(&admin);

// Cap set so that one 80-point reward (8000 tokens) exceeds it.
let token_contract_id = env.register_contract(None, learn_token::LearnToken);
let token_client = LearnTokenClient::new(&env, &token_contract_id);
token_client.initialize(
&admin,
&SorobanString::from_str(&env, "CLearn"),
&SorobanString::from_str(&env, "CLRN"),
&7,
&pt_contract_id,
&5000, // cap below the reward for score 80 (8000)
);

let learner = Address::generate(&env);
env.mock_all_auths();

let course_id = Symbol::new(&env, "course_1");
let quiz_id = Symbol::new(&env, "quiz_1");
create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80);

let supply_before = token_client.total_supply();
let balance_before = token_client.balance(&learner);

let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
token_client.claim_reward(&learner, &course_id, &quiz_id);
}));
assert!(
result.is_err(),
"claim exceeding supply cap must revert"
);

// No state change despite the successful cross-contract score read.
assert_eq!(token_client.total_supply(), supply_before);
assert_eq!(token_client.balance(&learner), balance_before);
// The reward claimed flag must also not have been set.
// Verify by checking that a second attempt fails for the same reason
// (cap), not for "reward already claimed".
let result2 = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
token_client.claim_reward(&learner, &course_id, &quiz_id);
}));
assert!(result2.is_err(), "second attempt must also revert");
// If the claimed flag had been set incorrectly the error would be
// "reward already claimed"; both reverts being for the cap means the
// claimed flag was never written.
assert_eq!(token_client.total_supply(), supply_before);
}

// ── Pause guard blocks cross-contract reward flow ────────────────────

/// When the learn-token contract is paused, `claim_reward` must be blocked
/// with "contract is paused" before the cross-contract score fetch even
/// occurs. The progress-tracker state and token state must be unaffected.
#[test]
#[should_panic(expected = "contract is paused")]
fn test_paused_contract_blocks_cross_contract_claim_reward() {
let env = Env::default();
let (admin, token_id, pt_id) = setup(&env);
let token_client = LearnTokenClient::new(&env, &token_id);
let pt_client = ProgressTrackerClient::new(&env, &pt_id);

let learner = Address::generate(&env);
env.mock_all_auths();

let course_id = Symbol::new(&env, "course_1");
let quiz_id = Symbol::new(&env, "quiz_1");
create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 85);

// Grant admin the Pauser role so it can pause, then pause.
token_client.grant_role(
&admin,
&admin,
&AdminRole::Pauser,
);
token_client.pause(&admin);
assert!(token_client.is_paused());

// This must panic with "contract is paused" before reaching the
// cross-contract score fetch.
token_client.claim_reward(&learner, &course_id, &quiz_id);
}

/// After unpausing, the cross-contract reward claim must succeed as normal,
/// confirming the pause guard was the only obstacle (not a corrupted state).
#[test]
fn test_unpause_restores_cross_contract_claim_reward() {
let env = Env::default();
let (admin, token_id, pt_id) = setup(&env);
let token_client = LearnTokenClient::new(&env, &token_id);
let pt_client = ProgressTrackerClient::new(&env, &pt_id);

let learner = Address::generate(&env);
env.mock_all_auths();

let course_id = Symbol::new(&env, "course_1");
let quiz_id = Symbol::new(&env, "quiz_1");
create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 50);

token_client.grant_role(
&admin,
&admin,
&AdminRole::Pauser,
);
token_client.pause(&admin);
token_client.unpause(&admin);
assert!(!token_client.is_paused());

// Cross-contract claim must work normally after unpausing.
token_client.claim_reward(&learner, &course_id, &quiz_id);
// Score 50 * 100 = 5000
assert_eq!(token_client.balance(&learner), 5000);
assert_eq!(token_client.total_supply(), 5000);
}

// ── Double-claim: claimed flag persists through cross-contract read ───

/// After a successful cross-contract claim the "reward claimed" flag must
/// survive any subsequent cross-contract reads. A second `claim_reward`
/// for the same (learner, course, quiz) triple must be rejected with
/// "reward already claimed" — not "quiz not submitted" or any other error
/// — proving the flag is durable and the progress-tracker state is
/// unchanged (the score is still readable).
#[test]
fn test_claimed_flag_persists_and_blocks_second_cross_contract_attempt() {
let env = Env::default();
let (_admin, token_id, pt_id) = setup(&env);
let token_client = LearnTokenClient::new(&env, &token_id);
let pt_client = ProgressTrackerClient::new(&env, &pt_id);

let learner = Address::generate(&env);
env.mock_all_auths();

let course_id = Symbol::new(&env, "course_1");
let quiz_id = Symbol::new(&env, "quiz_1");
create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 90);

// First claim: cross-contract call succeeds, 9000 tokens minted.
token_client.claim_reward(&learner, &course_id, &quiz_id);
assert_eq!(token_client.balance(&learner), 9000);

// Progress-tracker score is still readable after the claim.
assert_eq!(
pt_client.get_quiz_score(&learner, &course_id, &quiz_id),
90,
"progress-tracker score must be unchanged after claim"
);

// Second claim attempt: must be caught by the claimed-flag check,
// panicking with "reward already claimed" before the cross-contract
// call even fires.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
token_client.claim_reward(&learner, &course_id, &quiz_id);
}));
assert!(result.is_err(), "second claim must revert");

// Balance and supply are unchanged from after the first (successful) claim.
assert_eq!(token_client.balance(&learner), 9000);
assert_eq!(token_client.total_supply(), 9000);
}

#[test]
fn test_cross_contract_calls_for_different_learners_are_isolated() {
let env = Env::default();
Expand Down