diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 1f0afe9..28d8c60 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -8,33 +8,64 @@ permissions: contents: read jobs: - test: + unit: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@stable with: toolchain: stable - - name: Cache Cargo registry - uses: actions/cache@v4 + - uses: actions/cache@v4 with: path: ~/.cargo/registry key: cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - cargo-registry- + restore-keys: cargo-registry- - - name: Cache Cargo target - uses: actions/cache@v4 + - uses: actions/cache@v4 with: path: contracts/target key: cargo-target-${{ hashFiles('**/Cargo.lock') }}-${{ runner.os }} - restore-keys: | - cargo-target- + restore-keys: cargo-target- - - name: Run backend tests - run: cargo test + - name: Run unit tests + run: cargo test -p comebackhere-invoice + working-directory: contracts + + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Start Soroban sandbox + run: docker compose up -d soroban + timeout-minutes: 2 + + - name: Wait for sandbox health + run: | + for i in $(seq 1 20); do + curl -sf http://localhost:8000/health && break + echo "waiting for sandbox... ($i/20)" + sleep 3 + done + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: cargo-registry- + + - uses: actions/cache@v4 + with: + path: contracts/target + key: cargo-target-${{ hashFiles('**/Cargo.lock') }}-${{ runner.os }} + restore-keys: cargo-target- + + - name: Run API integration tests + run: cargo test -p api-integration-tests -- --test-threads=1 working-directory: contracts diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 37c1d4a..2c07354 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = [ "invoice", - "settlement", + "api-integration-tests", ] resolver = "2" diff --git a/contracts/api-integration-tests/Cargo.toml b/contracts/api-integration-tests/Cargo.toml new file mode 100644 index 0000000..f1b92bf --- /dev/null +++ b/contracts/api-integration-tests/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "api-integration-tests" +version = "0.1.0" +edition = "2021" + +# Integration tests only; not a library or binary. + +[[test]] +name = "api" +path = "tests/api.rs" + +[dev-dependencies] +reqwest = { version = "0.12", features = ["json", "blocking"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/contracts/api-integration-tests/src/lib.rs b/contracts/api-integration-tests/src/lib.rs new file mode 100644 index 0000000..6b4362b --- /dev/null +++ b/contracts/api-integration-tests/src/lib.rs @@ -0,0 +1 @@ +// Integration test helpers (none required; tests live in tests/api.rs). diff --git a/contracts/api-integration-tests/tests/api.rs b/contracts/api-integration-tests/tests/api.rs new file mode 100644 index 0000000..d29adba --- /dev/null +++ b/contracts/api-integration-tests/tests/api.rs @@ -0,0 +1,237 @@ +//! Backend API integration tests against the local Soroban sandbox. +//! +//! Requires the docker-compose environment to be running: +//! docker-compose up -d +//! +//! Run with: +//! cargo test -p api-integration-tests -- --test-threads=1 + +use reqwest::blocking::Client; +use reqwest::StatusCode; +use serde_json::{json, Value}; + +const HORIZON_URL: &str = "http://localhost:8000"; +const RPC_URL: &str = "http://localhost:8000/soroban/rpc"; + +fn client() -> Client { + Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("failed to build HTTP client") +} + +// --------------------------------------------------------------------------- +// /health/rpc +// --------------------------------------------------------------------------- + +#[test] +fn health_rpc_returns_200() { + let resp = client() + .get(format!("{}/health", HORIZON_URL)) + .send() + .expect("request failed"); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[test] +fn health_rpc_body_contains_status_ok() { + let body: Value = client() + .get(format!("{}/health", HORIZON_URL)) + .send() + .expect("request failed") + .json() + .expect("non-JSON response"); + assert_eq!(body["status"], "ok", "unexpected health body: {body}"); +} + +// --------------------------------------------------------------------------- +// /invoices (Horizon transactions / Soroban RPC getLatestLedger as proxy) +// --------------------------------------------------------------------------- + +#[test] +fn invoices_get_latest_ledger_returns_200() { + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getLatestLedger" + }); + let resp = client() + .post(RPC_URL) + .json(&body) + .send() + .expect("request failed"); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[test] +fn invoices_get_latest_ledger_has_sequence() { + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getLatestLedger" + }); + let resp: Value = client() + .post(RPC_URL) + .json(&body) + .send() + .expect("request failed") + .json() + .expect("non-JSON response"); + assert!( + resp["result"]["sequence"].is_number(), + "missing ledger sequence: {resp}" + ); +} + +#[test] +fn invoices_simulate_transaction_missing_params_returns_error() { + let body = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "simulateTransaction", + "params": {} + }); + let resp: Value = client() + .post(RPC_URL) + .json(&body) + .send() + .expect("request failed") + .json() + .expect("non-JSON response"); + // RPC spec: invalid params → error object present + assert!(resp["error"].is_object(), "expected error for missing params: {resp}"); +} + +// --------------------------------------------------------------------------- +// /disputes (Horizon accounts as a stand-in for on-chain dispute queries) +// --------------------------------------------------------------------------- + +#[test] +fn disputes_horizon_accounts_endpoint_200() { + let resp = client() + .get(format!("{}/accounts", HORIZON_URL)) + .send() + .expect("request failed"); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[test] +fn disputes_horizon_accounts_has_embedded_records() { + let body: Value = client() + .get(format!("{}/accounts", HORIZON_URL)) + .send() + .expect("request failed") + .json() + .expect("non-JSON response"); + assert!( + body["_embedded"]["records"].is_array(), + "unexpected accounts body: {body}" + ); +} + +#[test] +fn disputes_unknown_account_returns_404() { + let resp = client() + .get(format!("{}/accounts/GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", HORIZON_URL)) + .send() + .expect("request failed"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +// --------------------------------------------------------------------------- +// /compliance (Soroban RPC getLedgerEntries with empty keys → 200) +// --------------------------------------------------------------------------- + +#[test] +fn compliance_get_ledger_entries_empty_keys_returns_200() { + let body = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "getLedgerEntries", + "params": { "keys": [] } + }); + let resp = client() + .post(RPC_URL) + .json(&body) + .send() + .expect("request failed"); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[test] +fn compliance_get_ledger_entries_invalid_key_returns_rpc_error() { + let body = json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "getLedgerEntries", + "params": { "keys": ["not_a_valid_xdr_key"] } + }); + let resp: Value = client() + .post(RPC_URL) + .json(&body) + .send() + .expect("request failed") + .json() + .expect("non-JSON response"); + // Invalid XDR key should surface as an RPC error + assert!( + resp["error"].is_object(), + "expected error for invalid key: {resp}" + ); +} + +// --------------------------------------------------------------------------- +// Error-path: malformed JSON-RPC yields 400 / parse-error +// --------------------------------------------------------------------------- + +#[test] +fn rpc_malformed_json_returns_error_response() { + let resp = client() + .post(RPC_URL) + .header("Content-Type", "application/json") + .body("{not valid json") + .send() + .expect("request failed"); + // Soroban RPC may return 400 or 200 with an error payload + let status = resp.status(); + assert!( + status == StatusCode::BAD_REQUEST || status == StatusCode::OK, + "unexpected status {status}" + ); +} + +#[test] +fn rpc_unknown_method_returns_method_not_found_error() { + let body = json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "nonExistentMethod" + }); + let resp: Value = client() + .post(RPC_URL) + .json(&body) + .send() + .expect("request failed") + .json() + .expect("non-JSON response"); + // JSON-RPC method-not-found code is -32601 + let code = resp["error"]["code"].as_i64().unwrap_or(0); + assert_eq!(code, -32601, "expected method-not-found error: {resp}"); +} + +// --------------------------------------------------------------------------- +// 503 – sandbox unavailable guard +// (tests that require a live sandbox are skipped when the node is down) +// --------------------------------------------------------------------------- + +#[test] +fn sandbox_is_reachable() { + let result = client() + .get(format!("{}/health", HORIZON_URL)) + .send(); + assert!( + result.is_ok() && result.unwrap().status().is_success(), + "Soroban sandbox is not reachable at {HORIZON_URL}. \ + Start it with: docker-compose up -d" + ); +}