taskfast-client 0.15.3

Typed HTTP client for the TaskFast marketplace API (generated from spec/openapi.yaml).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
// SPDX-License-Identifier: MIT
//! Fixture tests for `TaskFastClient`: auth header injection + typed error
//! translation from wiremock responses.
//!
//! Covers the four error classes the CLI's exit-code taxonomy depends on:
//!   - 401 → `Error::Auth`           → exit code 3
//!   - 422 → `Error::Validation`     → exit code 7
//!   - 429 → `Error::RateLimited`    → exit code 4
//!   - 503 (exhausted retries) → `Error::Server` → exit code 6
//!
//! Also verifies the retry loop *does* retry on 5xx, and stops after
//! `max_attempts` without infinite-looping.

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};

/// `GET /platform/config` is the ideal fixture endpoint — no auth required
/// *by contract*, no request body, no path params. We still send the
/// `X-API-Key` header (the client injects it unconditionally) and the mock
/// verifies presence.
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:?}"),
    }
}

/// Counts requests. Each 503 is retried per RetryPolicy; after max_attempts
/// the call_with_retry wrapper surfaces Error::Server.
#[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;

    // Minimal delay policy so the test doesn't sleep seconds.
    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)"
    );
}

/// 5xx with eventual success on attempt 2 returns Ok and didn't exhaust retries.
#[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);
}

/// F5 regression. reqwest's default redirect policy follows up to 10 hops,
/// and because `X-API-Key` is a custom header (not `Authorization`) it
/// would be replayed to the redirected host — a single attacker 302 from
/// the TaskFast API would exfiltrate the PAT. The client pins
/// `Policy::none` so any 3xx surfaces to the caller as a typed error
/// instead of silently following.
#[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;

    // `target` emits a 302 pointing at `hop`. If the client followed it,
    // `hop` would receive the X-API-Key header.
    Mock::given(method("GET"))
        .and(path("/platform/config"))
        .respond_with(
            ResponseTemplate::new(302).insert_header("location", format!("{}/stolen", hop.uri())),
        )
        .mount(&target)
        .await;

    // `hop` has no matching mocks; any request to it panics the test.
    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");
    // Any error shape is fine — what matters is that we didn't traverse
    // to `hop` and replay the API key.
    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);

    // Second call must hit the cache — counter stays at 1.
    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() {
    // Tempo Zone entries advertise only their RPC proxy, so the server emits
    // null wss_url/explorer_url for them (gh#949). The pre-0.17.2 non-nullable
    // fields rejected the whole payload and broke every command while a zone
    // venue was registered.
    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:?}"),
    }
}