use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use taskfast_client::{map_api_error, Error, RetryPolicy, TaskFastClient};
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
fn fixture_client(base_url: &str) -> TaskFastClient {
TaskFastClient::from_api_key(base_url, "test-key-123").expect("construct client")
}
#[tokio::test]
async fn x_api_key_header_is_injected() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/platform/config"))
.and(header("x-api-key", "test-key-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let client = fixture_client(&server.uri());
client
.inner()
.get_platform_config()
.await
.expect("header matcher satisfied");
}
#[tokio::test]
async fn status_401_maps_to_auth_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/platform/config"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": "invalid_api_key",
"message": "API key is not recognized",
})))
.mount(&server)
.await;
let client = fixture_client(&server.uri());
let err = client.inner().get_platform_config().await.unwrap_err();
match map_api_error(err).await {
Error::Auth(msg) => assert_eq!(msg, "HTTP 401: API key is not recognized"),
other => panic!("expected Auth, got {other:?}"),
}
}
#[tokio::test]
async fn status_422_maps_to_validation_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/platform/config"))
.respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
"error": "missing_field",
"message": "name is required",
})))
.mount(&server)
.await;
let client = fixture_client(&server.uri());
let err = client.inner().get_platform_config().await.unwrap_err();
match map_api_error(err).await {
Error::Validation { code, message } => {
assert_eq!(code, "missing_field");
assert_eq!(message, "name is required");
}
other => panic!("expected Validation, got {other:?}"),
}
}
#[tokio::test]
async fn status_429_maps_to_rate_limited_with_retry_after() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/platform/config"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("retry-after", "7")
.set_body_json(
serde_json::json!({ "error": "rate_limited", "message": "slow down" }),
),
)
.mount(&server)
.await;
let client = fixture_client(&server.uri());
let err = client.inner().get_platform_config().await.unwrap_err();
match map_api_error(err).await {
Error::RateLimited { retry_after } => {
assert_eq!(retry_after, Duration::from_secs(7));
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[tokio::test]
async fn status_429_without_retry_after_defaults_to_one_second() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/platform/config"))
.respond_with(ResponseTemplate::new(429))
.mount(&server)
.await;
let client = fixture_client(&server.uri());
let err = client.inner().get_platform_config().await.unwrap_err();
match map_api_error(err).await {
Error::RateLimited { retry_after } => {
assert_eq!(retry_after, Duration::from_secs(1));
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[derive(Clone, Default)]
struct CountingRespond(Arc<AtomicUsize>);
impl Respond for CountingRespond {
fn respond(&self, _: &Request) -> ResponseTemplate {
self.0.fetch_add(1, Ordering::SeqCst);
ResponseTemplate::new(503).set_body_json(serde_json::json!({
"error": "upstream_down",
"message": "database unreachable",
}))
}
}
#[tokio::test]
async fn retry_503_exhausts_max_attempts_then_returns_server_error() {
let server = MockServer::start().await;
let counter = CountingRespond::default();
Mock::given(method("GET"))
.and(path("/platform/config"))
.respond_with(counter.clone())
.mount(&server)
.await;
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
};
let client = fixture_client(&server.uri()).with_retry_policy(policy);
let inner = client.inner();
let result: Result<(), Error> = client
.call_with_retry(|_attempt| async move {
match inner.get_platform_config().await {
Ok(_) => Ok(()),
Err(e) => Err(map_api_error(e).await),
}
})
.await;
match result {
Err(Error::Server(msg)) => assert_eq!(msg, "HTTP 503: database unreachable"),
other => panic!("expected Server error after retry exhaustion, got {other:?}"),
}
assert_eq!(
counter.0.load(Ordering::SeqCst),
3,
"expected exactly 3 attempts (max_attempts=3)"
);
}
#[derive(Clone)]
struct FailThenSucceed {
count: Arc<AtomicUsize>,
fail_until_attempt: usize,
}
impl Respond for FailThenSucceed {
fn respond(&self, _: &Request) -> ResponseTemplate {
let n = self.count.fetch_add(1, Ordering::SeqCst) + 1;
if n < self.fail_until_attempt {
ResponseTemplate::new(503)
} else {
ResponseTemplate::new(200).set_body_json(serde_json::json!({}))
}
}
}
#[tokio::test]
async fn retry_recovers_when_upstream_heals() {
let server = MockServer::start().await;
let responder = FailThenSucceed {
count: Arc::new(AtomicUsize::new(0)),
fail_until_attempt: 2,
};
Mock::given(method("GET"))
.and(path("/platform/config"))
.respond_with(responder.clone())
.mount(&server)
.await;
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
};
let client = fixture_client(&server.uri()).with_retry_policy(policy);
let inner = client.inner();
let result: Result<(), Error> = client
.call_with_retry(|_| async move {
match inner.get_platform_config().await {
Ok(_) => Ok(()),
Err(e) => Err(map_api_error(e).await),
}
})
.await;
assert!(
result.is_ok(),
"expected success on attempt 2, got {result:?}"
);
assert_eq!(responder.count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn redirect_is_not_followed_so_api_key_cannot_leak_cross_host() {
let target = MockServer::start().await;
let hop = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/platform/config"))
.respond_with(
ResponseTemplate::new(302).insert_header("location", format!("{}/stolen", hop.uri())),
)
.mount(&target)
.await;
let _unused = &hop;
let client = fixture_client(&target.uri());
let err = client
.inner()
.get_platform_config()
.await
.expect_err("3xx must surface as an error, not a silent follow");
let _ = err;
}
#[tokio::test]
async fn fetch_network_config_parses_and_caches() {
let server = MockServer::start().await;
let counter = Arc::new(AtomicUsize::new(0));
#[derive(Clone)]
struct CountingCfg {
count: Arc<AtomicUsize>,
}
impl Respond for CountingCfg {
fn respond(&self, _: &Request) -> ResponseTemplate {
self.count.fetch_add(1, Ordering::SeqCst);
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"networks": {
"testnet": {
"chain_id": 42431,
"rpc_url": "http://example/rpc/testnet",
"wss_url": "wss://rpc.tempo-moderato.xyz",
"explorer_url": "https://explorer.tempo-moderato.xyz",
"default_stablecoin": "PathUSD",
},
"mainnet": {
"chain_id": 4217,
"rpc_url": "http://example/rpc/mainnet",
"wss_url": "wss://rpc.tempo.xyz",
"explorer_url": "https://explorer.tempo.xyz",
"default_stablecoin": null,
}
}
}))
}
}
Mock::given(method("GET"))
.and(path("/config/network"))
.respond_with(CountingCfg {
count: counter.clone(),
})
.mount(&server)
.await;
let client = fixture_client(&server.uri());
let first = client.fetch_network_config().await.expect("first fetch");
let testnet = first.entry("testnet").expect("testnet present");
assert_eq!(testnet.chain_id, 42_431);
assert_eq!(testnet.rpc_url, "http://example/rpc/testnet");
let mainnet = first.entry("mainnet").expect("mainnet present");
assert_eq!(mainnet.chain_id, 4_217);
assert!(mainnet.default_stablecoin.is_none());
let (name, entry) = first.entry_by_chain_id(42_431).expect("reverse lookup");
assert_eq!(name, "testnet");
assert_eq!(entry.chain_id, 42_431);
let _ = client.fetch_network_config().await.expect("second fetch");
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"fetch_network_config must cache the response"
);
}
#[tokio::test]
async fn fetch_network_config_tolerates_null_zone_urls() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/config/network"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"networks": {
"zone-421700005": {
"chain_id": 421700005,
"rpc_url": "http://example/rpc/zone-421700005",
"wss_url": null,
"explorer_url": null,
"default_stablecoin": "AlphaUSD",
}
}
})))
.mount(&server)
.await;
let client = fixture_client(&server.uri());
let cfg = client
.fetch_network_config()
.await
.expect("payload with null zone urls parses");
let zone = cfg.entry("zone-421700005").expect("zone entry present");
assert_eq!(zone.chain_id, 421_700_005);
assert!(zone.wss_url.is_none());
assert!(zone.explorer_url.is_none());
}
#[tokio::test]
async fn post_json_rpc_forwards_body_verbatim() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rpc/testnet"))
.and(header("x-api-key", "test-key-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": "0xdeadbeef"
})))
.mount(&server)
.await;
let client = fixture_client(&server.uri());
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "eth_blockNumber",
"params": []
});
let resp = client
.post_json_rpc("testnet", &body)
.await
.expect("proxy passthrough");
assert_eq!(
resp["result"],
serde_json::Value::String("0xdeadbeef".into())
);
}
#[tokio::test]
async fn post_json_rpc_429_maps_to_rate_limited_with_retry_after() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rpc/mainnet"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("retry-after", "42")
.set_body_json(serde_json::json!({
"error": "rate_limited",
"limit": 10,
"window_seconds": 60,
"retry_after_seconds": 42
})),
)
.mount(&server)
.await;
let client = fixture_client(&server.uri());
let body = serde_json::json!({"jsonrpc":"2.0","id":1,"method":"eth_blockNumber"});
let err = client
.post_json_rpc("mainnet", &body)
.await
.expect_err("429 must surface");
match err {
Error::RateLimited { retry_after } => assert_eq!(retry_after, Duration::from_secs(42)),
other => panic!("expected RateLimited, got {other:?}"),
}
}