Async client over reqwest+tokio with builder-pattern Client, serde RunRequest/RunResult/FileToken/AppToken types, thiserror Error enum, streaming multipart upload via tokio::fs::File, and 14 wiremock-backed integration tests covering healthz, run-success-json, run-success-text, run-502, run-with-files, file-upload, token mint/list/revoke, auth failure, missing-token short-circuit, transport timeout, and builder validation. Doc-tested. cargo test, cargo clippy --all-targets -D warnings, and cargo build --examples all clean.
362 lines
11 KiB
Rust
362 lines
11 KiB
Rust
//! Integration tests against an in-process wiremock server.
|
|
|
|
use std::io::Write;
|
|
use std::time::Duration;
|
|
|
|
use clawdforge::{Client, Error, RunRequest, TokenCreateRequest};
|
|
use serde_json::json;
|
|
use wiremock::matchers::{body_json, body_string_contains, header, method, path, path_regex};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
fn make_client(server: &MockServer) -> Client {
|
|
Client::builder()
|
|
.base_url(server.uri())
|
|
.token("cf_test_token")
|
|
.admin_token("admin_test_token")
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
.expect("client builds")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn healthz_returns_payload() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/healthz"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"ok": true,
|
|
"claude_present": true,
|
|
"claude_version": "claude 1.2.3"
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let c = make_client(&server);
|
|
let h = c.healthz().await.unwrap();
|
|
assert!(h.ok);
|
|
assert!(h.claude_present);
|
|
assert_eq!(h.claude_version.as_deref(), Some("claude 1.2.3"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_success_with_json_result() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/run"))
|
|
.and(header("authorization", "Bearer cf_test_token"))
|
|
.and(body_json(json!({
|
|
"prompt": "give me json",
|
|
"model": "sonnet"
|
|
})))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"ok": true,
|
|
"result": {"hello": "world", "n": 42},
|
|
"duration_ms": 1234,
|
|
"stop_reason": "end_turn"
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let c = make_client(&server);
|
|
let r = c
|
|
.run(RunRequest {
|
|
prompt: "give me json".into(),
|
|
model: Some("sonnet".into()),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(r.ok);
|
|
assert_eq!(r.duration_ms, 1234);
|
|
assert_eq!(r.stop_reason.as_deref(), Some("end_turn"));
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct Reply {
|
|
hello: String,
|
|
n: i32,
|
|
}
|
|
let parsed: Reply = r.as_json().unwrap();
|
|
assert_eq!(parsed.hello, "world");
|
|
assert_eq!(parsed.n, 42);
|
|
assert!(r.as_text().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_success_with_text_result() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/run"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"ok": true,
|
|
"result": "plain string reply",
|
|
"duration_ms": 50,
|
|
"stop_reason": "end_turn"
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let c = make_client(&server);
|
|
let r = c
|
|
.run(RunRequest {
|
|
prompt: "say hi".into(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(r.as_text(), Some("plain string reply"));
|
|
let json_attempt: Result<serde_json::Map<String, serde_json::Value>, _> = r.as_json();
|
|
assert!(json_attempt.is_err(), "string should not deserialize as map");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_502_surfaces_api_error() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/run"))
|
|
.respond_with(ResponseTemplate::new(502).set_body_json(json!({
|
|
"ok": false,
|
|
"error": "claude exited 1",
|
|
"stderr": "boom",
|
|
"duration_ms": 10,
|
|
"stop_reason": null
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let c = make_client(&server);
|
|
let err = c
|
|
.run(RunRequest {
|
|
prompt: "fail".into(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect_err("should fail");
|
|
|
|
match err {
|
|
Error::Api { status, body } => {
|
|
assert_eq!(status, 502);
|
|
assert!(body.contains("claude exited 1"), "body was {body}");
|
|
// Demonstrate caller-side recovery via RunFailure.
|
|
let parsed: clawdforge::RunFailure =
|
|
serde_json::from_str(&body).expect("body is RunFailure JSON");
|
|
assert!(!parsed.ok);
|
|
assert_eq!(parsed.error.as_deref(), Some("claude exited 1"));
|
|
}
|
|
other => panic!("unexpected error variant: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_with_files_passes_through() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/run"))
|
|
.and(body_json(json!({
|
|
"prompt": "use the file",
|
|
"files": ["ff_abc", "ff_def"]
|
|
})))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"ok": true,
|
|
"result": "saw 2 files",
|
|
"duration_ms": 100,
|
|
"stop_reason": "end_turn"
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let c = make_client(&server);
|
|
let r = c
|
|
.run(RunRequest {
|
|
prompt: "use the file".into(),
|
|
files: Some(vec!["ff_abc".into(), "ff_def".into()]),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(r.as_text(), Some("saw 2 files"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_file_streams_multipart() {
|
|
let server = MockServer::start().await;
|
|
|
|
// wiremock can't easily decode multipart, so we fingerprint the bytes:
|
|
// the file's contents (as a UTF-8 substring) and the form field names.
|
|
Mock::given(method("POST"))
|
|
.and(path("/files"))
|
|
.and(header("authorization", "Bearer cf_test_token"))
|
|
.and(body_string_contains("hello-from-rust-test"))
|
|
.and(body_string_contains("name=\"file\""))
|
|
.and(body_string_contains("name=\"ttl_secs\""))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"file_token": "ff_xyz",
|
|
"ttl_secs": 1800,
|
|
"size": 20
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut tmp = tempfile::NamedTempFile::new().unwrap();
|
|
write!(tmp, "hello-from-rust-test").unwrap();
|
|
tmp.flush().unwrap();
|
|
|
|
let c = make_client(&server);
|
|
let ft = c.upload_file(tmp.path(), Some(1800)).await.unwrap();
|
|
assert_eq!(ft.file_token, "ff_xyz");
|
|
assert_eq!(ft.ttl_secs, 1800);
|
|
assert_eq!(ft.size, 20);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_create_token() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/admin/tokens"))
|
|
.and(header("authorization", "Bearer admin_test_token"))
|
|
.and(body_json(json!({
|
|
"name": "cauldron",
|
|
"ip_cidrs": ["172.24.0.0/16"]
|
|
})))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"name": "cauldron",
|
|
"token": "cf_brandnew",
|
|
"ip_cidrs": ["172.24.0.0/16"]
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let c = make_client(&server);
|
|
let t = c
|
|
.create_token(TokenCreateRequest {
|
|
name: "cauldron".into(),
|
|
ip_cidrs: vec!["172.24.0.0/16".into()],
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(t.name, "cauldron");
|
|
assert_eq!(t.token, "cf_brandnew");
|
|
assert_eq!(t.ip_cidrs, vec!["172.24.0.0/16".to_string()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_list_tokens() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/admin/tokens"))
|
|
.and(header("authorization", "Bearer admin_test_token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"tokens": [
|
|
{"name": "cauldron", "ip_cidrs": ["172.24.0.0/16"], "created_at": 1700000000},
|
|
{"name": "petalparse", "ip_cidrs": [], "created_at": 1700000100, "last_seen": 1700001000}
|
|
]
|
|
})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let c = make_client(&server);
|
|
let list = c.list_tokens().await.unwrap();
|
|
assert_eq!(list.tokens.len(), 2);
|
|
assert_eq!(list.tokens[0].name, "cauldron");
|
|
// unknown server-added field captured by `extra`.
|
|
assert!(list.tokens[1].extra.contains_key("last_seen"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_revoke_token() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("DELETE"))
|
|
.and(path_regex(r"^/admin/tokens/.+"))
|
|
.and(header("authorization", "Bearer admin_test_token"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let c = make_client(&server);
|
|
c.revoke_token("cauldron").await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unauthorized_response_maps_to_auth_error() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/run"))
|
|
.respond_with(ResponseTemplate::new(401).set_body_string("missing token"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let c = make_client(&server);
|
|
let err = c
|
|
.run(RunRequest {
|
|
prompt: "nope".into(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect_err("should fail");
|
|
matches!(err, Error::Auth(_)).then_some(()).unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_app_token_short_circuits_run() {
|
|
// Build a client without an app token but with admin set.
|
|
let server = MockServer::start().await;
|
|
let c = Client::builder()
|
|
.base_url(server.uri())
|
|
.admin_token("admin_only")
|
|
.build()
|
|
.unwrap();
|
|
|
|
let err = c
|
|
.run(RunRequest {
|
|
prompt: "x".into(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect_err("should fail without app token");
|
|
match err {
|
|
Error::Auth(msg) => assert!(msg.contains("no app token")),
|
|
other => panic!("unexpected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn transport_timeout_surfaces_as_transport_error() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/healthz"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_delay(Duration::from_millis(2_000))
|
|
.set_body_json(json!({
|
|
"ok": true,
|
|
"claude_present": true,
|
|
"claude_version": "x"
|
|
})),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let c = Client::builder()
|
|
.base_url(server.uri())
|
|
.token("cf_x")
|
|
.timeout(Duration::from_millis(150))
|
|
.build()
|
|
.unwrap();
|
|
let err = c.healthz().await.expect_err("should time out");
|
|
assert!(matches!(err, Error::Transport(_)), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn builder_rejects_missing_base_url() {
|
|
let err = Client::builder().build().expect_err("should fail");
|
|
assert!(matches!(err, Error::Config(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn builder_rejects_bad_scheme() {
|
|
let err = Client::builder()
|
|
.base_url("ftp://nope")
|
|
.build()
|
|
.expect_err("should fail");
|
|
assert!(matches!(err, Error::Config(_)));
|
|
}
|