Skip to content
Merged
Show file tree
Hide file tree
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
61 changes: 46 additions & 15 deletions .github/workflows/backend-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion contracts/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
members = [
"invoice",
"settlement",
"api-integration-tests",
]
resolver = "2"
15 changes: 15 additions & 0 deletions contracts/api-integration-tests/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions contracts/api-integration-tests/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// Integration test helpers (none required; tests live in tests/api.rs).
237 changes: 237 additions & 0 deletions contracts/api-integration-tests/tests/api.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
Loading