From 72d6dd765d1a69d5a3901ef3e4530992ca2f5001 Mon Sep 17 00:00:00 2001 From: Ossai Onyekachi Jane Date: Sun, 26 Apr 2026 16:53:35 +0100 Subject: [PATCH 1/9] Add sandbox.env for local development configuration Added configuration for the TeachLink Sandbox Network, including network URLs, deployer keys, and mock service ports. --- config/networks/sandbox.env | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 config/networks/sandbox.env diff --git a/config/networks/sandbox.env b/config/networks/sandbox.env new file mode 100644 index 00000000..255fc408 --- /dev/null +++ b/config/networks/sandbox.env @@ -0,0 +1,28 @@ +# ============================================================= +# TeachLink Sandbox Network Configuration +# Issue #381 — Local testing sandbox environment +# ============================================================= +# This config is for LOCAL development only. +# Never use real keys or mainnet values here. + +STELLAR_NETWORK=sandbox +STELLAR_HORIZON_URL=http://localhost:8000 +STELLAR_SOROBAN_RPC_URL=http://localhost:8000/soroban/rpc + +# Pre-funded sandbox deployer key (safe to commit - sandbox only) +DEPLOYER_SECRET_KEY=SCZANGBA5YHTNYVS23C4QSQH5ODHVIMTQJZNFNLXFMG7VZ57SB42ONHU + +# Pre-funded sandbox test account keys +TEST_ACCOUNT_1_SECRET=SDHOAMBNLGCE27Q64SHG6KBSSRMLV3QLVJSRMGM65JJXTVFVKGAL4LGS +TEST_ACCOUNT_2_SECRET=SBQPDFUGLMWJYEYXFRM5TQX3AX2BR47WKI8FDS2VAKZ4YKQZRP64FGP5 +TEST_ACCOUNT_3_SECRET=SAVDOZS4FVSYLBCM7YIMHZUZSZSZEZM7HHQMHW6WIZ3QEG7A7BXQZAAH + +# Sandbox timing (faster than real network) +SANDBOX_BLOCK_TIME_MS=500 +SANDBOX_TX_TIMEOUT_SECS=10 + +# Mock service ports +MOCK_HORIZON_PORT=8000 +MOCK_TOKEN_PORT=8001 +SANDBOX_RPC_PORT=8002 +EOF From f6f4c7c07128c65fd97d67a83c97178803b23257 Mon Sep 17 00:00:00 2001 From: Ossai Onyekachi Jane Date: Sun, 26 Apr 2026 16:55:30 +0100 Subject: [PATCH 2/9] Enhance docker-compose with Stellar and sandbox services Added local Stellar node and sandbox services to docker-compose. --- docker-compose.yml | 81 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1f4a37ca..1bceac9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,62 @@ version: '3.8' services: - # Development environment with all tools + # ───────────────────────────────────────────── + # LOCAL STELLAR NODE (sandbox network) + # Issue #381 — provides isolated local blockchain + # ───────────────────────────────────────────── + stellar-local: + image: stellar/quickstart:latest + container_name: teachlink-stellar-local + ports: + - "8000:8000" # Horizon API + - "8001:8001" # Friendbot (free test XLM) + environment: + - ENABLE_SOROBAN_RPC=true + command: --local --enable-soroban-rpc + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000"] + interval: 5s + timeout: 3s + retries: 20 + networks: + - teachlink-network + + # ───────────────────────────────────────────── + # SANDBOX — full isolated test environment + # Run: docker-compose up sandbox + # ───────────────────────────────────────────── + sandbox: + build: + context: . + target: development + dockerfile: Dockerfile + container_name: teachlink-sandbox + depends_on: + stellar-local: + condition: service_healthy + volumes: + - .:/workspace + - cargo-cache:/usr/local/cargo/registry + - target-cache:/workspace/target + environment: + - RUST_BACKTRACE=1 + - CARGO_TERM_COLOR=always + - STELLAR_NETWORK=sandbox + - STELLAR_HORIZON_URL=http://stellar-local:8000 + - STELLAR_SOROBAN_RPC_URL=http://stellar-local:8000/soroban/rpc + - DEPLOYER_SECRET_KEY=SCZANGBA5YHTNYVS23C4QSQH5ODHVIMTQJZNFNLXFMG7VZ57SB42ONHU + - TEST_ACCOUNT_1_SECRET=SDHOAMBNLGCE27Q64SHG6KBSSRMLV3QLVJSRMGM65JJXTVFVKGAL4LGS + - TEST_ACCOUNT_2_SECRET=SBQPDFUGLMWJYEYXFRM5TQX3AX2BR47WKI8FDS2VAKZ4YKQZRP64FGP5 + - SANDBOX_BLOCK_TIME_MS=500 + working_dir: /workspace + command: cargo test --all-features -- --test-threads=1 + networks: + - teachlink-network + + # ───────────────────────────────────────────── + # EXISTING SERVICES (unchanged) + # ───────────────────────────────────────────── dev: build: context: . @@ -24,7 +79,6 @@ services: networks: - teachlink-network - # Builder service for creating optimized WASM builder: build: context: . @@ -39,7 +93,6 @@ services: networks: - teachlink-network - # Test runner service test: build: context: . @@ -55,7 +108,6 @@ services: networks: - teachlink-network - # Linter/formatter service lint: build: context: . @@ -79,24 +131,3 @@ volumes: networks: teachlink-network: driver: bridge - -# Usage instructions: -# -# Start development environment: -# docker-compose up dev -# docker-compose exec dev bash -# -# Build WASM: -# docker-compose run --rm builder -# -# Run tests: -# docker-compose run --rm test -# -# Run linter: -# docker-compose run --rm lint -# -# Build all services: -# docker-compose build -# -# Clean up: -# docker-compose down -v From e01b9f133a3f284c135cd9e5eccf8b47cac61ed8 Mon Sep 17 00:00:00 2001 From: Ossai Onyekachi Jane Date: Sun, 26 Apr 2026 17:02:40 +0100 Subject: [PATCH 3/9] Add SandboxEnv for isolated local testing Implement a comprehensive testing sandbox environment with mock capabilities for Stellar/Soroban. --- testing/sandbox/mod.rs | 128 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 testing/sandbox/mod.rs diff --git a/testing/sandbox/mod.rs b/testing/sandbox/mod.rs new file mode 100644 index 00000000..c65e9d91 --- /dev/null +++ b/testing/sandbox/mod.rs @@ -0,0 +1,128 @@ +//! # TeachLink Sandbox Environment +//! Issue #381 — Comprehensive testing sandbox +//! +//! Provides a fully isolated local test environment with: +//! - Mock Stellar/Soroban environment via soroban_sdk::Env +//! - Pre-funded test accounts +//! - Mock token contract +//! - Quick-iteration helpers + +pub mod fixtures; +pub mod mock_token; + +use soroban_sdk::{Address, Env}; + +/// Central sandbox environment used in all local tests. +/// Wraps soroban_sdk::Env with helpers for quick setup. +/// +/// # Example +/// ```rust +/// let sb = SandboxEnv::new(); +/// let alice = sb.accounts.alice(); +/// sb.fund_account(&alice, 1_000_0000000); +/// ``` +pub struct SandboxEnv { + /// The underlying Soroban mock environment + pub env: Env, + /// Pre-built named test accounts + pub accounts: fixtures::TestAccounts, +} + +impl SandboxEnv { + /// Create a fresh sandbox — call this at the top of every test. + /// Each call gives you a clean slate with no shared state. + pub fn new() -> Self { + let env = Env::default(); + // Allow all auth in sandbox — don't require real signatures + env.mock_all_auths(); + + let accounts = fixtures::TestAccounts::new(&env); + + Self { env, accounts } + } + + /// Fund an account with a given amount of stroops (1 XLM = 10_000_000 stroops). + pub fn fund_account(&self, _address: &Address, _amount_stroops: i128) { + // In the mock env, balances are managed by the mock token. + // This is a no-op hook — extend if you need balance assertions. + } + + /// Fast-forward the sandbox ledger by `n` seconds. + /// Useful for testing time-locked operations. + pub fn advance_time(&self, seconds: u64) { + self.env.ledger().with_mut(|l| { + l.timestamp += seconds; + l.sequence_number += 1; + }); + } + + /// Fast-forward by a number of ledger sequences. + pub fn advance_ledger(&self, ledgers: u32) { + self.env.ledger().with_mut(|l| { + l.sequence_number += ledgers; + l.timestamp += u64::from(ledgers) * 5; // ~5s per ledger + }); + } + + /// Print current sandbox ledger info (helpful for debugging). + pub fn print_state(&self) { + let ledger = self.env.ledger(); + println!( + "[Sandbox] Ledger: seq={} timestamp={}", + ledger.sequence(), + ledger.timestamp(), + ); + } +} + +impl Default for SandboxEnv { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sandbox_creates_clean_env() { + let sb = SandboxEnv::new(); + assert_eq!(sb.env.ledger().sequence(), 0); + } + + #[test] + fn sandbox_time_advance_works() { + let sb = SandboxEnv::new(); + let before = sb.env.ledger().timestamp(); + sb.advance_time(60); + let after = sb.env.ledger().timestamp(); + assert_eq!(after - before, 60); + } + + #[test] + fn sandbox_ledger_advance_works() { + let sb = SandboxEnv::new(); + let before = sb.env.ledger().sequence(); + sb.advance_ledger(10); + assert_eq!(sb.env.ledger().sequence() - before, 10); + } + + #[test] + fn sandbox_accounts_are_distinct() { + let sb = SandboxEnv::new(); + let alice = sb.accounts.alice(); + let bob = sb.accounts.bob(); + assert_ne!(alice, bob); + } + + #[test] + fn two_sandboxes_are_isolated() { + let sb1 = SandboxEnv::new(); + let sb2 = SandboxEnv::new(); + sb1.advance_time(999); + // sb2 is unaffected + assert_eq!(sb2.env.ledger().timestamp(), 0); + } +} +EOF From 8c4f87bbab90d99cdbc788df99825c704a9663e1 Mon Sep 17 00:00:00 2001 From: Ossai Onyekachi Jane Date: Sun, 26 Apr 2026 17:03:27 +0100 Subject: [PATCH 4/9] Add test fixtures for accounts and constants This file contains test fixtures, including named accounts and standard token amounts for testing. It also includes tests to verify account uniqueness and correctness of amount constants. --- testing/sandbox/fixtures.rs | 93 +++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 testing/sandbox/fixtures.rs diff --git a/testing/sandbox/fixtures.rs b/testing/sandbox/fixtures.rs new file mode 100644 index 00000000..e422086e --- /dev/null +++ b/testing/sandbox/fixtures.rs @@ -0,0 +1,93 @@ +cat > testing/sandbox/fixtures.rs << 'EOF' +//! # Test Fixtures +//! Issue #381 — Pre-built test accounts and data helpers +//! +//! Named accounts make tests readable: +//! alice = typical learner +//! bob = typical educator +//! carol = platform admin / third party +//! dave = adversarial / edge-case actor + +use soroban_sdk::{Address, Env}; + +/// Named test accounts for readable, expressive tests. +pub struct TestAccounts { + alice: Address, + bob: Address, + carol: Address, + dave: Address, +} + +impl TestAccounts { + /// Create all named accounts bound to the given environment. + pub fn new(env: &Env) -> Self { + Self { + alice: Address::generate(env), + bob: Address::generate(env), + carol: Address::generate(env), + dave: Address::generate(env), + } + } + + /// Alice — typical learner account + pub fn alice(&self) -> Address { self.alice.clone() } + + /// Bob — typical educator account + pub fn bob(&self) -> Address { self.bob.clone() } + + /// Carol — platform admin or neutral third party + pub fn carol(&self) -> Address { self.carol.clone() } + + /// Dave — adversarial or edge-case actor + pub fn dave(&self) -> Address { self.dave.clone() } +} + +/// Standard token amounts used across tests (in stroops, 1 XLM = 10_000_000) +pub mod amounts { + pub const ONE_XLM: i128 = 10_000_000; + pub const TEN_XLM: i128 = 100_000_000; + pub const HUNDRED_XLM: i128 = 1_000_000_000; + pub const THOUSAND_XLM: i128 = 10_000_000_000; + pub const PLATFORM_FEE_BPS: i128 = 250; // 2.5% +} + +/// Standard time values used across tests (in seconds) +pub mod time { + pub const ONE_MINUTE: u64 = 60; + pub const ONE_HOUR: u64 = 3_600; + pub const ONE_DAY: u64 = 86_400; + pub const ONE_WEEK: u64 = 604_800; + pub const ONE_MONTH: u64 = 2_592_000; +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::Env; + + #[test] + fn all_accounts_generated() { + let env = Env::default(); + let accounts = TestAccounts::new(&env); + // All four accounts must be distinct addresses + let all = [ + accounts.alice(), + accounts.bob(), + accounts.carol(), + accounts.dave(), + ]; + for i in 0..all.len() { + for j in (i + 1)..all.len() { + assert_ne!(all[i], all[j], "accounts[{i}] == accounts[{j}] — must be unique"); + } + } + } + + #[test] + fn amount_constants_are_correct() { + use amounts::*; + assert_eq!(ONE_XLM * 10, TEN_XLM); + assert_eq!(TEN_XLM * 10, HUNDRED_XLM); + assert_eq!(HUNDRED_XLM * 10, THOUSAND_XLM); + } +} From 3182630b1e3150c31d153ed299c7725c44faa387 Mon Sep 17 00:00:00 2001 From: Ossai Onyekachi Jane Date: Sun, 26 Apr 2026 17:03:59 +0100 Subject: [PATCH 5/9] Remove EOF marker from mod.rs --- testing/sandbox/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/sandbox/mod.rs b/testing/sandbox/mod.rs index c65e9d91..0f8e669b 100644 --- a/testing/sandbox/mod.rs +++ b/testing/sandbox/mod.rs @@ -125,4 +125,4 @@ mod tests { assert_eq!(sb2.env.ledger().timestamp(), 0); } } -EOF + From 2fa94eabc887fcb4d709aac0bc51c8332b9df048 Mon Sep 17 00:00:00 2001 From: Ossai Onyekachi Jane Date: Sun, 26 Apr 2026 22:25:30 +0100 Subject: [PATCH 6/9] Update fixtures.rs --- testing/sandbox/fixtures.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/sandbox/fixtures.rs b/testing/sandbox/fixtures.rs index e422086e..ad947e24 100644 --- a/testing/sandbox/fixtures.rs +++ b/testing/sandbox/fixtures.rs @@ -1,4 +1,4 @@ -cat > testing/sandbox/fixtures.rs << 'EOF' + //! # Test Fixtures //! Issue #381 — Pre-built test accounts and data helpers //! From db32f8fb726e22a2bb9ba8df8e0ebccca6204d48 Mon Sep 17 00:00:00 2001 From: Ossai Onyekachi Jane Date: Sun, 26 Apr 2026 22:27:15 +0100 Subject: [PATCH 7/9] Create mock_token.rs --- testing/sandbox/mock_token.rs | 260 ++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 testing/sandbox/mock_token.rs diff --git a/testing/sandbox/mock_token.rs b/testing/sandbox/mock_token.rs new file mode 100644 index 00000000..57a45f0d --- /dev/null +++ b/testing/sandbox/mock_token.rs @@ -0,0 +1,260 @@ + +//! # Mock Token Service +//! Issue #381 — Mock SEP-41 token for sandbox testing +//! +//! Lets tests mint, transfer, and check balances without +//! deploying a real token contract to any network. + +use soroban_sdk::{ + contract, contractimpl, contracttype, + token::TokenInterface, + Address, Env, String, +}; + +/// Internal storage keys for the mock token +#[contracttype] +enum DataKey { + Balance(Address), + Allowance(Address, Address), // (owner, spender) + Admin, + Decimals, + Name, + Symbol, +} + +/// A minimal SEP-41 compatible mock token. +/// Deploy this in sandbox tests instead of a real token. +#[contract] +pub struct MockToken; + +#[contractimpl] +impl MockToken { + /// Initialize the mock token. Call once after deploying. + pub fn initialize( + env: Env, + admin: Address, + decimals: u32, + name: String, + symbol: String, + ) { + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Decimals, &decimals); + env.storage().instance().set(&DataKey::Name, &name); + env.storage().instance().set(&DataKey::Symbol, &symbol); + } + + /// Mint tokens to any address. Only callable by admin. + /// In sandbox tests, admin is typically the `carol` account. + pub fn mint(env: Env, to: Address, amount: i128) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + assert!(amount > 0, "mint amount must be positive"); + + let current: i128 = env + .storage() + .persistent() + .get(&DataKey::Balance(to.clone())) + .unwrap_or(0); + + env.storage() + .persistent() + .set(&DataKey::Balance(to), &(current + amount)); + } + + /// Burn tokens from an address. Only callable by admin. + pub fn burn_from_admin(env: Env, from: Address, amount: i128) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + let current: i128 = env + .storage() + .persistent() + .get(&DataKey::Balance(from.clone())) + .unwrap_or(0); + + assert!(current >= amount, "insufficient balance to burn"); + + env.storage() + .persistent() + .set(&DataKey::Balance(from), &(current - amount)); + } + + /// Get balance of any address (no auth required — public). + pub fn balance_of(env: Env, address: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Balance(address)) + .unwrap_or(0) + } +} + +/// Standard token interface (subset used in tests) +#[contractimpl] +impl TokenInterface for MockToken { + fn allowance(env: Env, from: Address, spender: Address) -> i128 { + env.storage() + .temporary() + .get(&DataKey::Allowance(from, spender)) + .unwrap_or(0) + } + + fn approve(env: Env, from: Address, spender: Address, amount: i128, _expiration_ledger: u32) { + from.require_auth(); + env.storage() + .temporary() + .set(&DataKey::Allowance(from, spender), &amount); + } + + fn balance(env: Env, id: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Balance(id)) + .unwrap_or(0) + } + + fn transfer(env: Env, from: Address, to: Address, amount: i128) { + from.require_auth(); + + let from_balance: i128 = Self::balance(env.clone(), from.clone()); + assert!(from_balance >= amount, "insufficient balance"); + + let to_balance: i128 = Self::balance(env.clone(), to.clone()); + + env.storage() + .persistent() + .set(&DataKey::Balance(from), &(from_balance - amount)); + env.storage() + .persistent() + .set(&DataKey::Balance(to), &(to_balance + amount)); + } + + fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) { + spender.require_auth(); + + let allowance = Self::allowance(env.clone(), from.clone(), spender.clone()); + assert!(allowance >= amount, "insufficient allowance"); + + // Reduce allowance + env.storage() + .temporary() + .set(&DataKey::Allowance(from.clone(), spender), &(allowance - amount)); + + // Execute transfer + Self::transfer(env, from, to, amount); + } + + fn burn(env: Env, from: Address, amount: i128) { + from.require_auth(); + let current = Self::balance(env.clone(), from.clone()); + assert!(current >= amount, "insufficient balance to burn"); + env.storage() + .persistent() + .set(&DataKey::Balance(from), &(current - amount)); + } + + fn burn_from(env: Env, spender: Address, from: Address, amount: i128) { + spender.require_auth(); + let allowance = Self::allowance(env.clone(), from.clone(), spender.clone()); + assert!(allowance >= amount, "insufficient allowance"); + env.storage() + .temporary() + .set(&DataKey::Allowance(from.clone(), spender), &(allowance - amount)); + Self::burn(env, from, amount); + } + + fn decimals(env: Env) -> u32 { + env.storage().instance().get(&DataKey::Decimals).unwrap_or(7) + } + + fn name(env: Env) -> String { + env.storage().instance().get(&DataKey::Name).unwrap() + } + + fn symbol(env: Env) -> String { + env.storage().instance().get(&DataKey::Symbol).unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Env}; + + fn setup() -> (Env, MockTokenClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, MockToken); + let client = MockTokenClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize( + &admin, + &7u32, + &String::from_str(&env, "Mock TeachLink Token"), + &String::from_str(&env, "mTLT"), + ); + + (env, client, admin) + } + + #[test] + fn mint_increases_balance() { + let (_env, client, admin) = setup(); + let recipient = Address::generate(&_env); + + client.mint(&admin, &recipient, &1_000_0000000i128); + assert_eq!(client.balance(&recipient), 1_000_0000000i128); + } + + #[test] + fn transfer_moves_funds() { + let (_env, client, admin) = setup(); + let alice = Address::generate(&_env); + let bob = Address::generate(&_env); + + client.mint(&admin, &alice, &500_0000000i128); + client.transfer(&alice, &bob, &100_0000000i128); + + assert_eq!(client.balance(&alice), 400_0000000i128); + assert_eq!(client.balance(&bob), 100_0000000i128); + } + + #[test] + #[should_panic(expected = "insufficient balance")] + fn transfer_more_than_balance_panics() { + let (_env, client, admin) = setup(); + let alice = Address::generate(&_env); + let bob = Address::generate(&_env); + + client.mint(&admin, &alice, &10_0000000i128); + client.transfer(&alice, &bob, &999_0000000i128); // should panic + } + + #[test] + fn approve_and_transfer_from_works() { + let (_env, client, admin) = setup(); + let alice = Address::generate(&_env); + let spender = Address::generate(&_env); + let bob = Address::generate(&_env); + + client.mint(&admin, &alice, &200_0000000i128); + client.approve(&alice, &spender, &50_0000000i128, &999u32); + client.transfer_from(&spender, &alice, &bob, &50_0000000i128); + + assert_eq!(client.balance(&alice), 150_0000000i128); + assert_eq!(client.balance(&bob), 50_0000000i128); + assert_eq!(client.allowance(&alice, &spender), 0); + } + + #[test] + fn burn_reduces_balance() { + let (_env, client, admin) = setup(); + let alice = Address::generate(&_env); + + client.mint(&admin, &alice, &100_0000000i128); + client.burn(&alice, &30_0000000i128); + assert_eq!(client.balance(&alice), 70_0000000i128); + } +} From b9286812481d7920ea6f0c214e7e4026a8f1bd71 Mon Sep 17 00:00:00 2001 From: Ossai Onyekachi Jane Date: Sun, 26 Apr 2026 22:29:23 +0100 Subject: [PATCH 8/9] Create sandbox.sh --- testing/scripts/sandbox.sh | 127 +++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 testing/scripts/sandbox.sh diff --git a/testing/scripts/sandbox.sh b/testing/scripts/sandbox.sh new file mode 100644 index 00000000..3ffc58ae --- /dev/null +++ b/testing/scripts/sandbox.sh @@ -0,0 +1,127 @@ + +#!/usr/bin/env bash +# ============================================================= +# TeachLink Sandbox Runner +# Issue #381 — One-command sandbox environment +# +# Usage: +# ./scripts/sandbox.sh # Full sandbox run +# ./scripts/sandbox.sh --no-docker # Run tests locally (no Docker) +# ./scripts/sandbox.sh --keep-up # Don't tear down after tests +# ./scripts/sandbox.sh --help # Show this message +# ============================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +SANDBOX_ENV="$ROOT_DIR/config/networks/sandbox.env" + +# ── Colors ────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +log() { echo -e "${BLUE}[sandbox]${NC} $*"; } +ok() { echo -e "${GREEN}[sandbox]${NC} ✅ $*"; } +warn() { echo -e "${YELLOW}[sandbox]${NC} ⚠️ $*"; } +err() { echo -e "${RED}[sandbox]${NC} ❌ $*"; exit 1; } + +# ── Flags ──────────────────────────────────────────────────── +USE_DOCKER=true +KEEP_UP=false + +for arg in "$@"; do + case $arg in + --no-docker) USE_DOCKER=false ;; + --keep-up) KEEP_UP=true ;; + --help) + grep '^#' "$0" | grep -v '/usr/bin' | sed 's/^# \?//' + exit 0 + ;; + esac +done + +# ── Header ─────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}╔════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ TeachLink Sandbox — Issue #381 ║${NC}" +echo -e "${BOLD}╚════════════════════════════════════════╝${NC}" +echo "" + +cd "$ROOT_DIR" + +# ── Load sandbox environment ───────────────────────────────── +if [[ -f "$SANDBOX_ENV" ]]; then + log "Loading sandbox config from $SANDBOX_ENV" + set -a + # shellcheck source=/dev/null + source "$SANDBOX_ENV" + set +a + ok "Sandbox config loaded (network=$STELLAR_NETWORK)" +else + err "Sandbox config not found at $SANDBOX_ENV — run setup first" +fi + +# ── Docker path ────────────────────────────────────────────── +if [[ "$USE_DOCKER" == true ]]; then + command -v docker >/dev/null 2>&1 || err "Docker not found. Install from https://docs.docker.com/get-docker/" + + log "Starting local Stellar node..." + docker-compose up -d stellar-local + + log "Waiting for Stellar node to be healthy..." + TRIES=0 + until curl -sf http://localhost:8000 >/dev/null 2>&1; do + TRIES=$((TRIES + 1)) + if [[ $TRIES -gt 30 ]]; then + err "Stellar node didn't start after 30 tries. Check: docker-compose logs stellar-local" + fi + echo -n "." + sleep 2 + done + echo "" + ok "Stellar node is up at http://localhost:8000" + + log "Running sandbox test suite via Docker..." + docker-compose run --rm sandbox + + if [[ "$KEEP_UP" == false ]]; then + log "Tearing down sandbox..." + docker-compose stop stellar-local sandbox + docker-compose rm -f stellar-local sandbox + ok "Sandbox stopped and cleaned up" + else + warn "Sandbox kept running (--keep-up). Stop with: docker-compose down" + fi + +# ── Local (no Docker) path ─────────────────────────────────── +else + log "Running sandbox tests locally (no Docker)..." + warn "Local mode uses Soroban's in-process mock environment only." + warn "For full network simulation, run without --no-docker." + + export STELLAR_NETWORK=sandbox + export RUST_BACKTRACE=1 + + cargo test --all-features -- --test-threads=1 2>&1 + + ok "Local sandbox tests complete" +fi + +# ── Summary ────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}${GREEN}══════════════════════════════════════════${NC}" +echo -e "${BOLD}${GREEN} Sandbox run complete! ✅${NC}" +echo -e "${BOLD}${GREEN}══════════════════════════════════════════${NC}" +echo "" +echo " Next steps:" +echo " • Check test output above for any failures" +echo " • Add new tests in testing/sandbox/" +echo " • Run again anytime: ./scripts/sandbox.sh" +echo "" + +chmod +x scripts/sandbox.sh \ No newline at end of file From aae3da6f48a5c6485fdadfa29e4483db15fe6423 Mon Sep 17 00:00:00 2001 From: Ossai Onyekachi Jane Date: Sun, 26 Apr 2026 22:31:56 +0100 Subject: [PATCH 9/9] Create sandbox.md --- testing/sandbox.md | 98 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 testing/sandbox.md diff --git a/testing/sandbox.md b/testing/sandbox.md new file mode 100644 index 00000000..2f63ea46 --- /dev/null +++ b/testing/sandbox.md @@ -0,0 +1,98 @@ + +# TeachLink Testing Sandbox + +> Issue #381 — Comprehensive local testing environment + +The sandbox gives every developer a fully isolated, reproducible +environment to write and run tests — no testnet, no real keys, no +waiting for network confirmations. + +--- + +## Quick Start + +```bash +# One command — starts everything, runs tests, cleans up +./scripts/sandbox.sh + +# Run tests only (no Docker required) +./scripts/sandbox.sh --no-docker + +# Keep the local node running after tests (for manual exploration) +./scripts/sandbox.sh --keep-up +``` + +--- + +## What the Sandbox Provides + +| Feature | Description | +|---|---| +| Local Stellar node | Full Soroban-enabled node, no testnet needed | +| Mock token | SEP-41 compatible token — mint freely in tests | +| Named test accounts | `alice`, `bob`, `carol`, `dave` — readable tests | +| Time control | `advance_time()` and `advance_ledger()` helpers | +| Isolated environment | Every test gets a clean slate | +| Fast iteration | No network delay — tests run in milliseconds | + +--- + +## Using the Sandbox in Your Tests + +```rust +use crate::testing::sandbox::SandboxEnv; +use crate::testing::sandbox::fixtures::amounts; + +#[test] +fn test_reward_distribution() { + // 1. Create a fresh sandbox + let sb = SandboxEnv::new(); + + // 2. Use named accounts + let educator = sb.accounts.bob(); + let learner = sb.accounts.alice(); + + // 3. Use amount constants + let reward = amounts::TEN_XLM; + + // 4. Advance time if your contract needs it + sb.advance_time(fixtures::time::ONE_DAY); + + // 5. Write assertions normally + assert!(reward > 0); +} +``` + +--- + +## Using the Mock Token + +```rust +use crate::testing::sandbox::{SandboxEnv, mock_token::MockToken}; + +#[test] +fn test_escrow_with_token() { + let sb = SandboxEnv::new(); + + // Deploy the mock token + let token_id = sb.env.register_contract(None, MockToken); + let token = MockTokenClient::new(&sb.env, &token_id); + + // Initialize it + token.initialize( + &sb.accounts.carol(), // carol = admin + &7u32, + &String::from_str(&sb.env, "TeachLink Token"), + &String::from_str(&sb.env, "TLT"), + ); + + // Mint tokens freely — no real XLM needed + token.mint(&sb.accounts.carol(), &sb.accounts.alice(), &1_000_0000000i128); + + assert_eq!(token.balance(&sb.accounts.alice()), 1_000_0000000i128); +} +``` + +--- + +## File Structure \ No newline at end of file