#![cfg(feature = "async")]
mod common;
use std::time::{Duration, Instant};
use caretly::{Client, Dpmm, Error, Format, RenderOptions};
use common::{closed_url, MockServer, Reply, LABEL};
fn client(server: &MockServer) -> Client {
Client::builder()
.api_key("zpl_test_abc")
.base_url(server.url())
.max_retries(0)
.build()
.unwrap()
}
#[tokio::test]
async fn parses_success_headers() {
let server = MockServer::start(vec![Reply::pdf()]);
let result = client(&server)
.render(LABEL, RenderOptions::new())
.await
.unwrap();
assert_eq!(result.bytes, b"%PDF-1.7 fake");
assert_eq!(result.content_type, "application/pdf");
assert_eq!(result.page_count, 3);
assert_eq!(result.file_page_counts, vec![2, 1]);
assert_eq!(result.labels_billed, Some(3));
let rate_limit = result.rate_limit.unwrap();
assert_eq!((rate_limit.limit, rate_limit.remaining), (200, 199));
assert_eq!(
result.server_timing.as_deref(),
Some("read;dur=0.1, parse;dur=0.2, total;dur=1.0")
);
}
#[tokio::test]
async fn sends_label_auth_and_query() {
let server = MockServer::start(vec![Reply::pdf()]);
let options = RenderOptions::new()
.format(Format::Png)
.page(2)
.dpmm(Dpmm::D12)
.timings(true);
client(&server).render(LABEL, options).await.unwrap();
let requests = server.requests();
assert_eq!(requests.len(), 1);
let request = &requests[0];
assert_eq!(request.method, "POST");
assert_eq!(
request.target,
"/render?format=png&page=2&dpmm=12&timings=1"
);
assert_eq!(request.header("authorization"), Some("Bearer zpl_test_abc"));
assert_eq!(
request.header("content-type"),
Some("text/plain; charset=utf-8")
);
assert_eq!(
request.header("user-agent"),
Some(concat!("caretly-rust/", env!("CARGO_PKG_VERSION")))
);
assert_eq!(
request.header("idempotency-key"),
None,
"no retries, no generated key"
);
assert_eq!(request.header("content-encoding"), None);
assert_eq!(request.body, LABEL.as_bytes());
}
#[tokio::test]
async fn omits_unset_query_and_empty_key() {
let server = MockServer::start(vec![Reply::pdf()]);
let client = Client::builder()
.api_key("")
.base_url(server.url())
.build()
.unwrap();
client.render(LABEL, RenderOptions::new()).await.unwrap();
let request = &server.requests()[0];
assert_eq!(request.target, "/render");
assert_eq!(request.header("authorization"), None);
}
#[tokio::test]
async fn sends_multipart_files() {
let server = MockServer::start(vec![Reply::pdf()]);
let files = [
("a.zpl", "^XA^FDa^FS^XZ".as_bytes()),
("b.zpl", "^XA^FDb^FS^XZ".as_bytes()),
];
let result = client(&server)
.render_files(files, RenderOptions::new())
.await
.unwrap();
assert_eq!(result.file_page_counts, vec![2, 1]);
let request = &server.requests()[0];
let content_type = request.header("content-type").unwrap();
let boundary = content_type
.strip_prefix("multipart/form-data; boundary=")
.expect(content_type);
let body = String::from_utf8(request.body.clone()).unwrap();
let parts: Vec<&str> = body.split(&format!("--{boundary}")).collect();
assert_eq!(
parts.len(),
4,
"two parts plus preamble and epilogue: {body}"
);
assert!(parts[1].contains("Content-Disposition: form-data; name=\"file\"; filename=\"a.zpl\""));
assert!(parts[1].ends_with("^XA^FDa^FS^XZ\r\n"));
assert!(parts[2].contains("filename=\"b.zpl\""));
assert!(parts[2].ends_with("^XA^FDb^FS^XZ\r\n"));
assert_eq!(parts[3], "--\r\n");
}
#[cfg(feature = "gzip")]
#[tokio::test]
async fn gzips_body_when_compress_is_set() {
let server = MockServer::start(vec![Reply::pdf()]);
client(&server)
.render(LABEL, RenderOptions::new().compress(true))
.await
.unwrap();
let request = &server.requests()[0];
assert_eq!(request.header("content-encoding"), Some("gzip"));
assert_eq!(common::gunzip(&request.body), LABEL.as_bytes());
}
#[tokio::test]
async fn maps_error_statuses() {
for status in [400, 401, 402, 413, 415, 429, 503, 500, 404] {
let server = MockServer::start(vec![
Reply::new(status, "something went wrong\n").header("Retry-After", "7")
]);
let error = client(&server)
.render(LABEL, RenderOptions::new())
.await
.unwrap_err();
let expected = match status {
400 => matches!(error, Error::BadRequest(_)),
401 => matches!(error, Error::Authentication(_)),
402 => matches!(error, Error::PaymentRequired(_)),
413 => matches!(error, Error::PayloadTooLarge(_)),
415 => matches!(error, Error::UnsupportedEncoding(_)),
429 => matches!(error, Error::RateLimited(_)),
503 => matches!(error, Error::Overloaded(_)),
_ => matches!(error, Error::Api(_)),
};
assert!(expected, "{status} mapped to {error:?}");
let api = error.api_error().unwrap();
assert_eq!(api.status, status);
assert_eq!(api.message, "something went wrong");
assert_eq!(api.retry_after, Some(Duration::from_secs(7)));
assert_eq!(
server.requests().len(),
1,
"{status} must not be retried with max_retries 0"
);
}
}
#[tokio::test]
async fn retries_429_honoring_retry_after() {
let server = MockServer::start(vec![
Reply::new(429, "slow down").header("Retry-After", "1"),
Reply::pdf(),
]);
let client = Client::builder()
.api_key("k")
.base_url(server.url())
.build()
.unwrap();
let start = Instant::now();
let result = client.render(LABEL, RenderOptions::new()).await.unwrap();
assert!(
start.elapsed() >= Duration::from_secs(1),
"waited {:?}",
start.elapsed()
);
assert_eq!(result.page_count, 3);
let requests = server.requests();
assert_eq!(requests.len(), 2);
let key = requests[0]
.header("idempotency-key")
.expect("generated idempotency key");
assert_eq!(key.len(), 36);
assert_eq!(
requests[1].header("idempotency-key"),
Some(key),
"same key on the retry"
);
assert_eq!(requests[1].body, requests[0].body);
}
#[tokio::test]
async fn retries_503_then_gives_up() {
let server = MockServer::start(vec![Reply::new(503, "busy").header("Retry-After", "0")]);
let client = Client::builder()
.api_key("k")
.base_url(server.url())
.max_retries(2)
.build()
.unwrap();
let options = RenderOptions::new().idempotency_key("order-42");
let error = client.render(LABEL, options).await.unwrap_err();
assert!(matches!(error, Error::Overloaded(_)), "{error:?}");
let requests = server.requests();
assert_eq!(requests.len(), 3, "first attempt plus two retries");
assert!(requests
.iter()
.all(|r| r.header("idempotency-key") == Some("order-42")));
}
#[tokio::test]
async fn does_not_retry_other_statuses() {
let server = MockServer::start(vec![Reply::new(500, "boom"), Reply::pdf()]);
let client = Client::builder()
.api_key("k")
.base_url(server.url())
.build()
.unwrap();
let error = client
.render(LABEL, RenderOptions::new())
.await
.unwrap_err();
assert!(matches!(error, Error::Api(_)));
assert_eq!(server.requests().len(), 1);
}
#[tokio::test]
async fn retries_connection_errors() {
let client = Client::builder()
.api_key("k")
.base_url(closed_url())
.max_retries(1)
.build()
.unwrap();
let start = Instant::now();
let error = client
.render(LABEL, RenderOptions::new())
.await
.unwrap_err();
assert!(matches!(error, Error::Connection(_)), "{error:?}");
assert!(
start.elapsed() >= Duration::from_millis(500),
"backed off once"
);
}
#[tokio::test]
async fn times_out() {
let server = MockServer::start(vec![Reply::pdf().delay(Duration::from_secs(2))]);
let client = Client::builder()
.api_key("k")
.base_url(server.url())
.timeout(Duration::from_millis(200))
.build()
.unwrap();
let error = client
.render(LABEL, RenderOptions::new())
.await
.unwrap_err();
assert!(matches!(error, Error::Timeout(_)), "{error:?}");
assert_eq!(server.requests().len(), 1, "timeouts are not retried");
}
#[tokio::test]
async fn validates_before_sending() {
let server = MockServer::start(vec![Reply::pdf()]);
let client = client(&server);
let page_zero = client.render_png(LABEL, 0).await.unwrap_err();
assert!(matches!(page_zero, Error::InvalidArgument(_)));
let long_key = client
.render(LABEL, RenderOptions::new().idempotency_key("k".repeat(256)))
.await;
assert!(matches!(long_key, Err(Error::InvalidArgument(_))));
assert!(matches!(Dpmm::try_from(7), Err(Error::InvalidArgument(_))));
let no_files: Vec<(String, String)> = Vec::new();
assert!(matches!(
client.render_files(no_files, RenderOptions::new()).await,
Err(Error::InvalidArgument(_))
));
assert!(server.requests().is_empty());
}
#[tokio::test]
async fn convenience_methods() {
let server = MockServer::start(vec![Reply::pdf()]);
let client = client(&server);
client.render_pdf(LABEL).await.unwrap();
client.render_png(LABEL, 3).await.unwrap();
let targets: Vec<_> = server.requests().into_iter().map(|r| r.target).collect();
assert_eq!(targets, ["/render?format=pdf", "/render?format=png&page=3"]);
}
#[tokio::test]
async fn health() {
let server = MockServer::start(vec![Reply::new(200, "ok")]);
assert!(client(&server).health().await.unwrap());
assert_eq!(server.requests()[0].method, "GET");
assert_eq!(server.requests()[0].target, "/health");
let unhealthy = MockServer::start(vec![Reply::new(500, "down")]);
assert!(!client(&unhealthy).health().await.unwrap());
let unreachable = Client::builder()
.api_key("")
.base_url(closed_url())
.build()
.unwrap();
assert!(matches!(
unreachable.health().await,
Err(Error::Connection(_))
));
}
#[tokio::test]
async fn saves_to_file() {
let server = MockServer::start(vec![Reply::pdf()]);
let result = client(&server).render_pdf(LABEL).await.unwrap();
let path = std::env::temp_dir().join(format!("caretly-test-{}.pdf", std::process::id()));
result.save(&path).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 fake");
std::fs::remove_file(path).unwrap();
}
#[test]
fn debug_redacts_api_key() {
let client = Client::builder()
.api_key("zpl_live_secret")
.build()
.unwrap();
let debug = format!("{client:?}");
assert!(!debug.contains("secret"), "{debug}");
assert!(debug.contains("<redacted>"));
}