Skip to content

Commit 043dd49

Browse files
andreabadessoclaude
andcommitted
test: add integration test framework and basic lifecycle tests
Test helpers for session/wallet management, plus tests for: - Health endpoint - Session create/list/destroy lifecycle - Wallet creation and blockchain sync - Session isolation (same wallet-id, different containers) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fcc0625 commit 043dd49

1 file changed

Lines changed: 346 additions & 0 deletions

File tree

tests/integration.rs

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
//! Integration tests for headless-orchestrator.
2+
//!
3+
//! These tests require:
4+
//! - Docker running
5+
//! - headless-orchestrator running on ORCHESTRATOR_URL (default: http://localhost:8150)
6+
//! - A Hathor fullnode accessible from Docker containers
7+
//!
8+
//! For full e2e tests (faucet, sends), the fullnode must have a funded wallet
9+
//! (e.g., hathor-forge-cli --start).
10+
//!
11+
//! Run: ORCHESTRATOR_URL=http://localhost:8150 cargo test --test integration -- --test-threads=1
12+
13+
use reqwest::Client;
14+
use serde_json::{json, Value};
15+
use std::time::Duration;
16+
17+
fn orchestrator_url() -> String {
18+
std::env::var("ORCHESTRATOR_URL").unwrap_or_else(|_| "http://localhost:8150".to_string())
19+
}
20+
21+
fn fullnode_url() -> String {
22+
std::env::var("FULLNODE_URL")
23+
.unwrap_or_else(|_| "http://localhost:49080".to_string())
24+
}
25+
26+
fn client() -> Client {
27+
Client::builder()
28+
.timeout(Duration::from_secs(60))
29+
.build()
30+
.unwrap()
31+
}
32+
33+
// ============================================================================
34+
// Helper Functions
35+
// ============================================================================
36+
37+
struct Session {
38+
id: String,
39+
api_key: String,
40+
}
41+
42+
async fn create_session(c: &Client) -> Session {
43+
let resp: Value = c
44+
.post(format!("{}/sessions", orchestrator_url()))
45+
.send()
46+
.await
47+
.expect("Failed to create session")
48+
.json()
49+
.await
50+
.expect("Failed to parse session response");
51+
52+
Session {
53+
id: resp["session_id"]
54+
.as_str()
55+
.expect("No session_id in response")
56+
.to_string(),
57+
api_key: resp["api_key"]
58+
.as_str()
59+
.expect("No api_key in response")
60+
.to_string(),
61+
}
62+
}
63+
64+
async fn destroy_session(c: &Client, session: &Session) {
65+
c.delete(format!("{}/sessions/{}", orchestrator_url(), session.id))
66+
.send()
67+
.await
68+
.expect("Failed to destroy session");
69+
}
70+
71+
fn api_url(session: &Session, path: &str) -> String {
72+
format!(
73+
"{}/sessions/{}/api{}",
74+
orchestrator_url(),
75+
session.id,
76+
path
77+
)
78+
}
79+
80+
async fn create_wallet(c: &Client, session: &Session, wallet_id: &str, seed: &str) {
81+
let resp: Value = c
82+
.post(api_url(session, "/start"))
83+
.json(&json!({
84+
"wallet-id": wallet_id,
85+
"seed": seed,
86+
}))
87+
.send()
88+
.await
89+
.expect("Failed to create wallet")
90+
.json()
91+
.await
92+
.expect("Failed to parse wallet creation response");
93+
94+
assert!(
95+
resp["success"].as_bool().unwrap_or(false),
96+
"Wallet creation failed: {:?}",
97+
resp
98+
);
99+
}
100+
101+
async fn wait_wallet_ready(c: &Client, session: &Session, wallet_id: &str) {
102+
for _ in 0..30 {
103+
tokio::time::sleep(Duration::from_secs(2)).await;
104+
105+
let resp: Value = c
106+
.get(api_url(session, "/wallet/status"))
107+
.header("X-Wallet-Id", wallet_id)
108+
.send()
109+
.await
110+
.expect("Failed to get wallet status")
111+
.json()
112+
.await
113+
.expect("Failed to parse wallet status");
114+
115+
if resp["statusCode"].as_i64() == Some(3) {
116+
return;
117+
}
118+
}
119+
panic!("Wallet {} did not become ready within 60s", wallet_id);
120+
}
121+
122+
async fn get_first_address(c: &Client, session: &Session, wallet_id: &str) -> String {
123+
let resp: Value = c
124+
.get(api_url(session, "/wallet/addresses"))
125+
.header("X-Wallet-Id", wallet_id)
126+
.send()
127+
.await
128+
.expect("Failed to get addresses")
129+
.json()
130+
.await
131+
.expect("Failed to parse addresses");
132+
133+
resp["addresses"]
134+
.as_array()
135+
.expect("No addresses array")
136+
.first()
137+
.expect("No addresses returned")
138+
.as_str()
139+
.expect("Address is not a string")
140+
.to_string()
141+
}
142+
143+
async fn get_balance(c: &Client, session: &Session, wallet_id: &str) -> (i64, i64) {
144+
get_token_balance(c, session, wallet_id, None).await
145+
}
146+
147+
async fn get_token_balance(
148+
c: &Client,
149+
session: &Session,
150+
wallet_id: &str,
151+
token: Option<&str>,
152+
) -> (i64, i64) {
153+
let mut url = api_url(session, "/wallet/balance");
154+
if let Some(token_uid) = token {
155+
url = format!("{}?token={}", url, token_uid);
156+
}
157+
158+
let resp: Value = c
159+
.get(&url)
160+
.header("X-Wallet-Id", wallet_id)
161+
.send()
162+
.await
163+
.expect("Failed to get balance")
164+
.json()
165+
.await
166+
.expect("Failed to parse balance");
167+
168+
let available = resp["available"].as_i64().unwrap_or(0);
169+
let locked = resp["locked"].as_i64().unwrap_or(0);
170+
(available, locked)
171+
}
172+
173+
async fn send_from_faucet(c: &Client, address: &str, amount_cents: i64) -> Value {
174+
c.post(format!("{}/v1a/wallet/send_tokens/", fullnode_url()))
175+
.json(&json!({
176+
"data": {
177+
"inputs": [],
178+
"outputs": [{
179+
"address": address,
180+
"value": amount_cents,
181+
}]
182+
}
183+
}))
184+
.send()
185+
.await
186+
.expect("Failed to send from faucet")
187+
.json()
188+
.await
189+
.expect("Failed to parse faucet send response")
190+
}
191+
192+
async fn send_tx(
193+
c: &Client,
194+
session: &Session,
195+
wallet_id: &str,
196+
address: &str,
197+
value: i64,
198+
) -> Value {
199+
c.post(api_url(session, "/wallet/simple-send-tx"))
200+
.header("X-Wallet-Id", wallet_id)
201+
.json(&json!({
202+
"address": address,
203+
"value": value,
204+
}))
205+
.send()
206+
.await
207+
.expect("Failed to send transaction")
208+
.json()
209+
.await
210+
.expect("Failed to parse send response")
211+
}
212+
213+
// ============================================================================
214+
// Seeds (different for each wallet to get unique addresses)
215+
// ============================================================================
216+
217+
const SEED_ALICE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
218+
const SEED_BOB: &str = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote";
219+
220+
// ============================================================================
221+
// Tests
222+
// ============================================================================
223+
224+
#[tokio::test]
225+
async fn test_health() {
226+
let c = client();
227+
let resp = c
228+
.get(format!("{}/health", orchestrator_url()))
229+
.send()
230+
.await
231+
.expect("Health check failed");
232+
233+
assert_eq!(resp.status(), 200);
234+
}
235+
236+
#[tokio::test]
237+
async fn test_session_lifecycle() {
238+
let c = client();
239+
240+
// Create
241+
let session = create_session(&c).await;
242+
assert!(!session.id.is_empty());
243+
assert!(!session.api_key.is_empty());
244+
println!("Created session: {} (api_key: {})", session.id, session.api_key);
245+
246+
// List — should contain our session
247+
let resp: Value = c
248+
.get(format!("{}/sessions", orchestrator_url()))
249+
.send()
250+
.await
251+
.unwrap()
252+
.json()
253+
.await
254+
.unwrap();
255+
256+
let sessions = resp["sessions"].as_array().unwrap();
257+
assert!(
258+
sessions
259+
.iter()
260+
.any(|s| s["session_id"].as_str() == Some(&session.id)),
261+
"Session not found in list"
262+
);
263+
264+
// Destroy
265+
destroy_session(&c, &session).await;
266+
267+
// List — should not contain our session
268+
let resp: Value = c
269+
.get(format!("{}/sessions", orchestrator_url()))
270+
.send()
271+
.await
272+
.unwrap()
273+
.json()
274+
.await
275+
.unwrap();
276+
277+
let sessions = resp["sessions"].as_array().unwrap();
278+
assert!(
279+
!sessions
280+
.iter()
281+
.any(|s| s["session_id"].as_str() == Some(&session.id)),
282+
"Session should have been destroyed"
283+
);
284+
}
285+
286+
#[tokio::test]
287+
async fn test_wallet_creation_and_sync() {
288+
let c = client();
289+
let session = create_session(&c).await;
290+
291+
// Create wallet
292+
create_wallet(&c, &session, "alice", SEED_ALICE).await;
293+
294+
// Wait for sync
295+
wait_wallet_ready(&c, &session, "alice").await;
296+
297+
// Get addresses
298+
let addr = get_first_address(&c, &session, "alice").await;
299+
// Mainnet addresses start with H, privatenet/testnet with W
300+
assert!(
301+
addr.starts_with('H') || addr.starts_with('W'),
302+
"Address should start with H or W, got {}",
303+
addr
304+
);
305+
println!("Alice first address: {}", addr);
306+
307+
// Get balance
308+
let (available, locked) = get_balance(&c, &session, "alice").await;
309+
println!("Alice balance: available={}, locked={}", available, locked);
310+
311+
// Cleanup
312+
destroy_session(&c, &session).await;
313+
}
314+
315+
#[tokio::test]
316+
async fn test_session_isolation() {
317+
let c = client();
318+
319+
// Create two sessions
320+
let session1 = create_session(&c).await;
321+
let session2 = create_session(&c).await;
322+
323+
// Create wallets with same wallet-id but different seeds
324+
create_wallet(&c, &session1, "wallet", SEED_ALICE).await;
325+
create_wallet(&c, &session2, "wallet", SEED_BOB).await;
326+
327+
// Wait for both to sync
328+
wait_wallet_ready(&c, &session1, "wallet").await;
329+
wait_wallet_ready(&c, &session2, "wallet").await;
330+
331+
// Get addresses — should be different because seeds are different
332+
let addr1 = get_first_address(&c, &session1, "wallet").await;
333+
let addr2 = get_first_address(&c, &session2, "wallet").await;
334+
335+
assert_ne!(
336+
addr1, addr2,
337+
"Same wallet-id on different sessions should have different addresses"
338+
);
339+
340+
println!("Session 1 wallet address: {}", addr1);
341+
println!("Session 2 wallet address: {}", addr2);
342+
343+
// Cleanup
344+
destroy_session(&c, &session1).await;
345+
destroy_session(&c, &session2).await;
346+
}

0 commit comments

Comments
 (0)