Files
invoiceninja-sdk/tests/client_requests.rs
T
awkawbandClaude Sonnet 5 fb670cbb08
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
Rename the crate from invoiceninjars to invoiceninja-sdk
The package name has never matched the repository it lives in
(gitea.awkawb.cloud/awkawb/invoiceninja-sdk, mirrored to
github.com/awkawb1/invoiceninja-sdk) — both pointed at `invoiceninja-sdk`
while `Cargo.toml` declared `invoiceninjars`. Lines everything up on the
name the repo has always used.

Cargo derives the crate's Rust identifier from the package name by
lowercasing hyphens to underscores, so every `use invoiceninjars::...`
becomes `use invoiceninja_sdk::...` — in doctests across `src/`, the four
examples, all four integration test files, and `README.md`. `Cargo.lock`'s
own self-entry picked up the new name on the next `cargo check`, which is
also when the rename gets its real check: every doctest and every test file
has to resolve the new path to compile at all, not just the ones a grep
happens to catch.

`flake.nix`'s `pname` and description follow, so `nix build` produces
`invoiceninja-sdk-0.1.0.drv` rather than a name that no longer matches
anything. `GOALS.md`'s two mentions — the hypothetical CLI's name and the
`cargo install` example — follow too, for the same reason.

Verified clean on both the dev toolchain and the 1.88 MSRV shell: full test
suite (209 tests), clippy, fmt, and `nix build`/`nix flake check`.

Not yet published to crates.io (checked: the name 404s there), so this
lands before the old name was ever claimed — no one's `Cargo.toml` breaks
today, but it's recorded as a Changed entry since `GOALS.md` asks for
breaking changes to be legible in the changelog rather than discovered at
compile time, and the crate is reachable as a git dependency regardless of
crates.io status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-25 09:43:05 -04:00

2934 lines
94 KiB
Rust

//! Request-plumbing tests: pagination, path escaping, headers, timeouts, and
//! error mapping.
//!
//! These run against a `wiremock` server bound to loopback, so they need no
//! network access and no credentials. `tests/model_roundtrip.rs` covers the
//! serde layer; this file covers everything between a method call and the
//! bytes on the wire.
use std::time::Duration;
use invoiceninja_sdk::{models::ListParams, Error, InvoiceNinjaClient};
use serde_json::json;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Builds a client pointed at the mock server.
fn client(server: &MockServer) -> InvoiceNinjaClient {
InvoiceNinjaClient::new(server.uri(), "test-token").expect("mock uri is a valid base url")
}
/// A `data`-wrapped list page carrying pagination metadata.
fn page(rows: usize, current_page: u64, total_pages: u64) -> serde_json::Value {
let data: Vec<_> = (0..rows)
.map(|i| json!({ "id": format!("id{i}") }))
.collect();
json!({
"data": data,
"meta": {
"pagination": {
"total": rows as u64 * total_pages,
"count": rows as u64,
"per_page": rows as u64,
"current_page": current_page,
"total_pages": total_pages,
}
}
})
}
// --- auth and headers ---------------------------------------------------
#[tokio::test]
async fn token_and_accept_headers_are_sent() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.and(header("X-Api-Token", "test-token"))
.and(header("Accept", "application/json"))
.and(header("X-Requested-With", "XMLHttpRequest"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
.mount(&server)
.await;
client(&server)
.list_clients(&ListParams::new())
.await
.expect("request should succeed");
}
#[tokio::test]
async fn api_password_header_is_attached_when_configured() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/users/u1"))
.and(header("X-API-PASSWORD", "hunter2"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "u1" } })))
.mount(&server)
.await;
let client = client(&server)
.with_api_password("hunter2")
.expect("password is a valid header value");
let user = client.get_user("u1").await.expect("request should succeed");
assert_eq!(user.id.as_deref(), Some("u1"));
}
#[tokio::test]
async fn api_password_header_is_absent_by_default() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
.mount(&server)
.await;
client(&server)
.list_clients(&ListParams::new())
.await
.expect("request should succeed");
let sent = &server.received_requests().await.unwrap()[0];
assert!(
sent.headers.get("X-API-PASSWORD").is_none(),
"password header must not be sent unless configured"
);
}
// --- path escaping ------------------------------------------------------
#[tokio::test]
async fn ids_are_escaped_into_a_single_path_segment() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "x" } })))
.mount(&server)
.await;
// A `/` in the id must not open a new path segment: the whole value
// stays under /clients rather than reaching /companies.
client(&server)
.get_client("../companies/1")
.await
.expect("escaped id should still reach the clients endpoint");
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(sent.url.path(), "/clients/%2E%2E%2Fcompanies%2F1");
}
/// Escaping by itself does not make a dot-only id safe: the WHATWG rules the
/// `url` crate follows decode `%2E` back to `.` before resolving dot
/// segments, so `clients/%2E%2E` still collapses to `/`. Such ids have to be
/// refused before a request is built.
#[tokio::test]
async fn dot_only_ids_are_rejected_before_any_request_is_sent() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
for id in ["..", ".", "..."] {
let err = client(&server)
.delete_client(id)
.await
.expect_err("a dot-only id must not be sent");
assert!(
matches!(err, Error::InvalidPathSegment(ref v) if v == id),
"expected InvalidPathSegment for {id:?}, got {err:?}"
);
}
assert!(
server.received_requests().await.unwrap().is_empty(),
"nothing should have reached the server"
);
}
#[tokio::test]
async fn empty_ids_are_rejected_rather_than_hitting_the_collection() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
// `delete_client("")` would otherwise address DELETE /clients.
let err = client(&server)
.delete_client("")
.await
.expect_err("an empty id must not be sent");
assert!(matches!(err, Error::InvalidPathSegment(_)), "got {err:?}");
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn query_injection_via_id_is_escaped() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/invoices/abc%3Fper_page%3D1/download"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4".to_vec()))
.mount(&server)
.await;
let pdf = client(&server)
.download_invoice("abc?per_page=1")
.await
.expect("escaped id should not inject a query string");
assert_eq!(pdf, b"%PDF-1.4");
}
#[tokio::test]
async fn ordinary_hashids_are_left_readable() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients/Wpmbk5ezJn"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "Wpmbk5ezJn" } })),
)
.mount(&server)
.await;
let got = client(&server)
.get_client("Wpmbk5ezJn")
.await
.expect("a normal id should pass through unescaped");
assert_eq!(got.id.as_deref(), Some("Wpmbk5ezJn"));
}
// --- pagination ---------------------------------------------------------
#[tokio::test]
async fn list_all_follows_total_pages_from_metadata() {
let server = MockServer::start().await;
for p in 1..=3u64 {
Mock::given(method("GET"))
.and(path("/clients"))
.and(query_param("page", p.to_string()))
.respond_with(ResponseTemplate::new(200).set_body_json(page(50, p, 3)))
.mount(&server)
.await;
}
let all = client(&server)
.list_all_clients(&ListParams::new())
.await
.expect("should walk every page");
assert_eq!(all.len(), 150, "expected all three pages");
}
/// The regression this rewrite exists for. The server caps `per_page` at
/// 5000, so asking for more returns a page shorter than requested while more
/// records remain. The old short-page-means-last heuristic stopped there and
/// silently dropped the rest.
#[tokio::test]
async fn capped_page_size_does_not_truncate_the_walk() {
let server = MockServer::start().await;
for p in 1..=2u64 {
Mock::given(method("GET"))
.and(path("/clients"))
.and(query_param("page", p.to_string()))
// 10 rows returned against a much larger requested page size.
.respond_with(ResponseTemplate::new(200).set_body_json(page(10, p, 2)))
.mount(&server)
.await;
}
let all = client(&server)
.list_all_clients(&ListParams::new().per_page(9999))
.await
.expect("should keep paging past a server-capped page");
assert_eq!(
all.len(),
20,
"a short page must not be read as the last page when metadata says otherwise"
);
}
#[tokio::test]
async fn per_page_is_clamped_to_the_server_maximum() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.and(query_param("per_page", "5000"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
.mount(&server)
.await;
client(&server)
.list_all_clients(&ListParams::new().per_page(100_000))
.await
.expect("oversized per_page should be clamped to 5000, not sent verbatim");
}
/// `per_page(0)` made the old loop compare `got < 0`, which nothing
/// satisfies — it paged forever. It must fall back to the default size.
#[tokio::test]
async fn zero_per_page_does_not_spin() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.and(query_param("per_page", "50"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
.mount(&server)
.await;
let all = tokio::time::timeout(
Duration::from_secs(10),
client(&server).list_all_clients(&ListParams::new().per_page(0)),
)
.await
.expect("must terminate rather than page forever")
.expect("request should succeed");
assert!(all.is_empty());
}
#[tokio::test]
async fn empty_page_terminates_even_if_metadata_claims_more() {
let server = MockServer::start().await;
// Metadata insists there are 99 pages, but the first comes back empty.
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 99)))
.mount(&server)
.await;
let all = tokio::time::timeout(
Duration::from_secs(10),
client(&server).list_all_clients(&ListParams::new()),
)
.await
.expect("an empty page must end the walk")
.expect("request should succeed");
assert!(all.is_empty());
}
#[tokio::test]
async fn list_all_falls_back_to_short_page_without_metadata() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.and(query_param("page", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": (0..50).map(|i| json!({ "id": format!("id{i}") })).collect::<Vec<_>>()
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/clients"))
.and(query_param("page", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{ "id": "last" }]
})))
.mount(&server)
.await;
let all = client(&server)
.list_all_clients(&ListParams::new())
.await
.expect("should page without metadata");
assert_eq!(all.len(), 51);
}
#[tokio::test]
async fn list_all_honours_a_starting_page() {
let server = MockServer::start().await;
for p in 2..=3u64 {
Mock::given(method("GET"))
.and(path("/clients"))
.and(query_param("page", p.to_string()))
.respond_with(ResponseTemplate::new(200).set_body_json(page(50, p, 3)))
.mount(&server)
.await;
}
let all = client(&server)
.list_all_clients(&ListParams::new().page(2))
.await
.expect("should start at the requested page");
assert_eq!(all.len(), 100, "pages 2 and 3 only");
}
#[tokio::test]
async fn custom_params_are_sent_on_every_page() {
let server = MockServer::start().await;
for p in 1..=2u64 {
Mock::given(method("GET"))
.and(path("/clients"))
.and(query_param("page", p.to_string()))
.and(query_param("client_id", "abc"))
.and(query_param("status", "active"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(50, p, 2)))
.mount(&server)
.await;
}
let all = client(&server)
.list_all_clients(&ListParams::new().status("active").param("client_id", "abc"))
.await
.expect("filters must survive pagination");
assert_eq!(all.len(), 100);
}
// --- error mapping ------------------------------------------------------
#[tokio::test]
async fn validation_errors_keep_the_whole_field_map() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"message": "The given data was invalid.",
"errors": {
"name": ["The name field is required."],
"contacts.0.email": ["The email must be valid.", "Already taken."]
}
})))
.mount(&server)
.await;
let err = client(&server)
.create_client(&Default::default())
.await
.expect_err("422 should surface as Error::Api");
match err {
Error::Api {
status,
message,
errors,
} => {
assert_eq!(status, 422);
assert_eq!(message, "The given data was invalid.");
assert_eq!(errors["name"], vec!["The name field is required."]);
assert_eq!(
errors["contacts.0.email"],
vec!["The email must be valid.", "Already taken."],
"every message is kept, not just the first"
);
}
other => panic!("expected Error::Api, got {other:?}"),
}
}
#[tokio::test]
async fn non_json_error_body_falls_back_to_the_raw_text() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(502).set_body_string("<html>bad gateway</html>"))
.mount(&server)
.await;
let err = client(&server)
.list_clients(&ListParams::new())
.await
.expect_err("502 should surface as Error::Api");
match err {
Error::Api {
status, message, ..
} => {
assert_eq!(status, 502);
assert!(
message.contains("bad gateway"),
"a non-JSON body should be preserved verbatim, got {message:?}"
);
}
other => panic!("expected Error::Api, got {other:?}"),
}
}
#[tokio::test]
async fn delete_reports_api_errors_rather_than_succeeding() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/clients/abc"))
.respond_with(ResponseTemplate::new(403).set_body_json(json!({ "message": "forbidden" })))
.mount(&server)
.await;
let err = client(&server)
.delete_client("abc")
.await
.expect_err("403 should not read as a successful delete");
assert!(matches!(err, Error::Api { status, .. } if status == 403));
}
// --- base url validation ------------------------------------------------
#[test]
fn base_url_without_a_scheme_is_rejected_at_construction() {
let err = InvoiceNinjaClient::new("invoicing.co/api/v1", "t")
.expect_err("a schemeless url should fail fast, not 404 on every call");
assert!(matches!(err, Error::InvalidBaseUrl(_)), "got {err:?}");
}
#[test]
fn non_http_base_url_is_rejected() {
let err = InvoiceNinjaClient::new("ftp://example.com/api/v1", "t")
.expect_err("only http(s) is usable");
assert!(matches!(err, Error::InvalidBaseUrl(_)), "got {err:?}");
}
#[test]
fn empty_base_url_is_rejected() {
let err = InvoiceNinjaClient::new(" ", "t").expect_err("empty base url should fail");
assert!(matches!(err, Error::InvalidBaseUrl(_)), "got {err:?}");
}
#[test]
fn trailing_slashes_are_tolerated() {
InvoiceNinjaClient::new("https://example.com/api/v1/", "t")
.expect("a trailing slash should not be an error");
}
// --- timeouts -----------------------------------------------------------
#[tokio::test]
async fn requests_give_up_once_the_timeout_elapses() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(page(0, 1, 1))
.set_delay(Duration::from_secs(30)),
)
.mount(&server)
.await;
let client = client(&server).with_timeout(Duration::from_millis(150));
let err = tokio::time::timeout(
Duration::from_secs(10),
client.list_clients(&ListParams::new()),
)
.await
.expect("the client's own timeout should fire well before this one")
.expect_err("a stalled response must not hang the caller");
assert!(
matches!(&err, Error::Http(e) if e.is_timeout()),
"expected a timeout error, got {err:?}"
);
}
// --- envelope handling --------------------------------------------------
#[tokio::test]
async fn bulk_response_without_a_data_key_reads_as_empty() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/invoices/bulk"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "message": "No Invoices Found" })),
)
.mount(&server)
.await;
let got = client(&server)
.bulk_invoices("archive", &["missing"])
.await
.expect("a no-match bulk action is not an error");
assert!(got.is_empty());
}
#[tokio::test]
async fn ping_accepts_an_empty_success_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ping"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
client(&server).ping().await.expect("ping should succeed");
}
// --- retry and backoff --------------------------------------------------
use invoiceninja_sdk::RetryPolicy;
/// Fast policy so the tests do not actually sleep for seconds.
fn quick_retry() -> RetryPolicy {
RetryPolicy::new(3)
.base_delay(Duration::from_millis(1))
.max_delay(Duration::from_millis(20))
}
#[tokio::test]
async fn transient_5xx_on_a_get_is_retried_until_it_succeeds() {
let server = MockServer::start().await;
// First two attempts fail, third succeeds.
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(503))
.up_to_n_times(2)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(1, 1, 1)))
.mount(&server)
.await;
let got = client(&server)
.with_retry_policy(quick_retry())
.list_clients(&ListParams::new())
.await
.expect("a GET should ride out transient 5xx");
assert_eq!(got.len(), 1);
assert_eq!(server.received_requests().await.unwrap().len(), 3);
}
/// The safety property that matters most here: a POST may already have
/// created a record even though the response failed, so replaying it could
/// double-charge. It must surface instead.
#[tokio::test]
async fn post_is_not_replayed_on_5xx() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(500).set_body_json(json!({ "message": "boom" })))
.mount(&server)
.await;
let err = client(&server)
.with_retry_policy(quick_retry())
.create_client(&Default::default())
.await
.expect_err("a failed create must not be retried");
assert!(matches!(err, Error::Api { status, .. } if status == 500));
assert_eq!(
server.received_requests().await.unwrap().len(),
1,
"exactly one create attempt may reach the server"
);
}
/// 429 is the exception: the server states it refused the request, so no
/// work was done and even a POST is safe to repeat.
#[tokio::test]
async fn post_is_retried_on_429_because_nothing_was_processed() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(429))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "new" } })))
.mount(&server)
.await;
let created = client(&server)
.with_retry_policy(quick_retry())
.create_client(&Default::default())
.await
.expect("a throttled create should be retried");
assert_eq!(created.id.as_deref(), Some("new"));
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn client_errors_are_never_retried() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients/abc"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({ "message": "not found" })))
.mount(&server)
.await;
let err = client(&server)
.with_retry_policy(quick_retry())
.get_client("abc")
.await
.expect_err("404 is not transient");
assert!(matches!(err, Error::Api { status, .. } if status == 404));
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn retries_are_bounded_by_the_policy() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(502))
.mount(&server)
.await;
let err = client(&server)
.with_retry_policy(RetryPolicy::new(2).base_delay(Duration::from_millis(1)))
.list_clients(&ListParams::new())
.await
.expect_err("should give up and surface the last error");
assert!(matches!(err, Error::Api { status, .. } if status == 502));
assert_eq!(
server.received_requests().await.unwrap().len(),
3,
"one initial attempt plus two retries"
);
}
#[tokio::test]
async fn retry_policy_none_attempts_once() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let _ = client(&server)
.with_retry_policy(RetryPolicy::none())
.list_clients(&ListParams::new())
.await
.expect_err("retrying is disabled");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn retry_after_longer_than_max_delay_is_surfaced_rather_than_slept_through() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "3600"))
.mount(&server)
.await;
let err = tokio::time::timeout(
Duration::from_secs(5),
client(&server)
.with_retry_policy(quick_retry())
.list_clients(&ListParams::new()),
)
.await
.expect("must not sleep for the full hour the server asked for")
.expect_err("the throttle should be reported");
assert!(matches!(err, Error::Api { status, .. } if status == 429));
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn a_short_retry_after_is_honoured() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
.mount(&server)
.await;
client(&server)
.with_retry_policy(quick_retry())
.list_clients(&ListParams::new())
.await
.expect("should wait the requested time and retry");
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn retried_requests_keep_their_body_and_headers() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/clients/abc"))
.and(header("X-Api-Token", "test-token"))
.respond_with(ResponseTemplate::new(503))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/clients/abc"))
.and(header("X-Api-Token", "test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "abc" } })))
.mount(&server)
.await;
let body = invoiceninja_sdk::models::Client {
name: "Acme".into(),
..Default::default()
};
client(&server)
.with_retry_policy(quick_retry())
.update_client("abc", &body)
.await
.expect("the replayed PUT should carry the same payload");
let sent = server.received_requests().await.unwrap();
assert_eq!(sent.len(), 2);
for request in &sent {
let json: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
assert_eq!(json["name"], "Acme", "body must survive the replay");
}
}
// --- narrowed response types --------------------------------------------
#[tokio::test]
async fn statics_decodes_into_typed_tables() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/statics"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"currencies": [{
"id": 1, "name": "US Dollar", "code": "USD", "symbol": "$",
"precision": 2, "thousand_separator": ",", "decimal_separator": ".",
"exchange_rate": 1.0, "swap_currency_symbol": false
}],
// payment_types is the table that returns a string id; the rest
// return numbers, and both must land as String.
"payment_types": [{ "id": "1", "name": "Bank Transfer" }],
"industries": [{ "id": 3, "name": "Advertising" }],
"countries": [{ "id": 840, "name": "United States", "iso_3166_2": "US" }],
"date_formats": [{ "id": 1, "format": "d/M/Y", "format_moment": "DD/MMM/YYYY" }],
"gateways": [{ "id": 1, "name": "Stripe", "fields": { "nested": true } }],
"bulk_updates": { "client": ["name"] },
"license_key": "self-host-only"
})))
.mount(&server)
.await;
let statics = client(&server)
.statics()
.await
.expect("statics should decode");
let usd = &statics.currencies[0];
assert_eq!(usd.id, "1", "a numeric id is normalised to a string");
assert_eq!(usd.code, "USD");
assert_eq!(usd.precision, Some(2));
assert_eq!(
statics.payment_types[0].id, "1",
"a string id passes through"
);
assert_eq!(statics.industries[0].name, "Advertising");
assert_eq!(statics.date_formats[0].format, "d/M/Y");
assert_eq!(
statics.countries[0].extra["iso_3166_2"], "US",
"untyped per-table fields stay reachable via extra"
);
assert!(statics.gateways.is_array(), "gateways stays untyped");
assert_eq!(
statics.extra["license_key"], "self-host-only",
"unmodeled top-level keys are preserved"
);
}
#[tokio::test]
async fn statics_tolerates_tables_it_does_not_model() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/statics"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"currencies": null,
"brand_new_table": [{ "whatever": 1 }]
})))
.mount(&server)
.await;
let statics = client(&server)
.statics()
.await
.expect("should not hard-fail");
assert!(statics.currencies.is_empty(), "null decodes as empty");
assert!(statics.extra.contains_key("brand_new_table"));
}
#[tokio::test]
async fn search_decodes_the_non_elasticsearch_shape() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"clients": [{
"name": "Acme", "type": "/client",
"id": "Wpmbk5ezJn", "path": "/clients/Wpmbk5ezJn"
}],
"client_contacts": [],
"invoices": [],
"projects": [],
"settings": [{ "name": "Tax Settings", "type": "/settings", "path": "/settings/tax" }]
})))
.mount(&server)
.await;
let results = client(&server)
.search(&json!({ "search": "acme" }))
.await
.expect("search should decode");
assert_eq!(results.clients[0].name, "Acme");
assert_eq!(results.clients[0].id, "Wpmbk5ezJn");
assert_eq!(results.clients[0].r#type, "/client");
assert_eq!(results.settings[0].name, "Tax Settings");
// Buckets only the Elasticsearch path returns are absent, not an error.
assert!(results.quotes.is_empty());
assert!(results.vendors.is_empty());
}
#[tokio::test]
async fn search_decodes_the_elasticsearch_shape() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"clients": [],
"quotes": [{ "name": "Q1", "type": "/quote", "id": "q1", "path": "/quotes/q1" }],
"purchase_orders": [{ "name": "PO", "type": "/purchase_order", "id": "p1", "path": "/x" }]
})))
.mount(&server)
.await;
let results = client(&server)
.search(&json!({ "search": "q" }))
.await
.expect("the wider shape should decode too");
assert_eq!(results.quotes[0].id, "q1");
assert_eq!(results.purchase_orders[0].name, "PO");
}
#[tokio::test]
async fn health_check_decodes_the_self_hosted_report() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/health_check"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"system_health": true,
"api_version": "5.8.56",
"php_version": {
"minimum_php_version": "8.1",
"current_php_version": "8.2.10",
"current_php_cli_version": "8.2.10",
"is_okay": true,
"memory_limit": "512M"
},
"simple_db_check": true,
"queue": "database",
"pdf_engine": "SnapPDF",
"is_docker": false,
"extensions": [{ "curl": true }]
})))
.mount(&server)
.await;
let health = client(&server).health_check().await.expect("should decode");
assert_eq!(health.system_health, Some(true));
assert_eq!(health.api_version, "5.8.56");
assert_eq!(health.queue, "database");
assert_eq!(
health
.php_version
.expect("php block present")
.current_php_version,
"8.2.10"
);
}
/// The hosted app skips the check and returns a bare message, so the same
/// type has to cover both deployments.
#[tokio::test]
async fn health_check_decodes_the_hosted_message_shape() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/health_check"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"message": "", "errors": []
})))
.mount(&server)
.await;
let health = client(&server).health_check().await.expect("should decode");
assert_eq!(
health.system_health, None,
"no verdict is reported on hosted"
);
assert!(health.api_version.is_empty());
}
/// Regression: the endpoint answers with a `data`-wrapped entity, but this
/// used to be decoded as a bare body, so callers got the envelope.
#[tokio::test]
async fn send_email_unwraps_the_data_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/emails"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": { "id": "inv1", "number": "INV-0001" }
})))
.mount(&server)
.await;
let invoice: invoiceninja_sdk::models::Invoice = client(&server)
.send_email(&json!({ "entity": "invoice", "entity_id": "inv1" }))
.await
.expect("should return the refreshed entity");
assert_eq!(invoice.id.as_deref(), Some("inv1"));
assert_eq!(invoice.number, "INV-0001");
}
#[tokio::test]
async fn generic_escape_hatches_still_work() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/statics"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "sizes": [] })))
.mount(&server)
.await;
let raw: serde_json::Value = client(&server)
.statics_as()
.await
.expect("statics_as should bypass the typed model");
assert!(raw["sizes"].is_array());
}
// --- secret handling ----------------------------------------------------
/// `reqwest`'s `Client` Debug prints its default headers, so the only thing
/// keeping the API token out of every `{:?}` log is the sensitive flag set
/// when the header is built. Lock that in.
#[test]
fn debug_output_never_contains_the_token_or_password() {
let client = InvoiceNinjaClient::new("https://example.com/api/v1", "SUPER_SECRET_TOKEN")
.unwrap()
.with_api_password("SUPER_SECRET_PASSWORD")
.unwrap();
let rendered = format!("{client:?}");
assert!(
!rendered.contains("SUPER_SECRET_TOKEN"),
"api token leaked into Debug output: {rendered}"
);
assert!(
!rendered.contains("SUPER_SECRET_PASSWORD"),
"api password leaked into Debug output: {rendered}"
);
assert!(
rendered.contains("Sensitive"),
"expected the headers to render as redacted, got: {rendered}"
);
}
// --- error quality ------------------------------------------------------
/// A misconfigured base URL or an intercepting proxy returns HTML with a 200.
/// The bare serde message ("expected value at line 1 column 1") does not hint
/// at that, so the body excerpt is what makes it diagnosable.
#[tokio::test]
async fn decode_failures_quote_the_body_that_arrived() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(
ResponseTemplate::new(200).set_body_string("<html><body>Login required</body></html>"),
)
.mount(&server)
.await;
let err = client(&server)
.list_clients(&ListParams::new())
.await
.expect_err("HTML is not a decodable list response");
let rendered = err.to_string();
assert!(
matches!(err, Error::Json { .. }),
"expected a decode error, got {err:?}"
);
assert!(
rendered.contains("Login required"),
"the excerpt should name what actually arrived, got: {rendered}"
);
}
#[tokio::test]
async fn decode_excerpts_are_truncated_and_single_line() {
let server = MockServer::start().await;
let huge = format!("<html>\n{}\n</html>", "x".repeat(50_000));
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(200).set_body_string(huge))
.mount(&server)
.await;
let err = client(&server)
.list_clients(&ListParams::new())
.await
.expect_err("should fail to decode");
let rendered = err.to_string();
assert!(
rendered.len() < 400,
"a huge body must not become a huge error, got {} chars",
rendered.len()
);
assert!(
!rendered.contains('\n'),
"the excerpt should be collapsed to one line"
);
assert!(rendered.contains('…'), "truncation should be marked");
}
#[tokio::test]
async fn empty_error_bodies_fall_back_to_the_status_reason() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/clients/abc"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let err = client(&server)
.with_retry_policy(RetryPolicy::none())
.delete_client("abc")
.await
.expect_err("404 is an error");
match err {
Error::Api { message, .. } => assert_eq!(
message, "Not Found",
"an empty body should borrow the status reason rather than render blank"
),
other => panic!("expected Error::Api, got {other:?}"),
}
}
#[tokio::test]
async fn a_json_error_body_with_no_message_still_says_something() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(403).set_body_json(json!({})))
.mount(&server)
.await;
let err = client(&server)
.with_retry_policy(RetryPolicy::none())
.list_clients(&ListParams::new())
.await
.expect_err("403 is an error");
assert!(err.to_string().contains("Forbidden"), "got {err}");
}
// --- retry budget -------------------------------------------------------
/// The request timeout is per attempt, so without a total budget four
/// attempts at 60s each would take four minutes. The budget caps the call.
#[tokio::test]
async fn the_total_budget_stops_retrying_even_with_retries_left() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(503).set_delay(Duration::from_millis(80)))
.mount(&server)
.await;
let policy = RetryPolicy::new(50)
.base_delay(Duration::from_millis(1))
.max_delay(Duration::from_millis(5))
.max_elapsed(Some(Duration::from_millis(250)));
let started = std::time::Instant::now();
let err = client(&server)
.with_retry_policy(policy)
.list_clients(&ListParams::new())
.await
.expect_err("should give up once the budget is spent");
assert!(matches!(err, Error::Api { status, .. } if status == 503));
assert!(
started.elapsed() < Duration::from_secs(3),
"50 retries were allowed but the budget should have ended it, took {:?}",
started.elapsed()
);
let attempts = server.received_requests().await.unwrap().len();
assert!(
(2..=6).contains(&attempts),
"expected the budget to allow a few attempts, got {attempts}"
);
}
#[tokio::test]
async fn no_budget_means_only_max_retries_bounds_the_call() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/clients"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let policy = RetryPolicy::new(4)
.base_delay(Duration::from_millis(1))
.max_delay(Duration::from_millis(2))
.max_elapsed(None);
let _ = client(&server)
.with_retry_policy(policy)
.list_clients(&ListParams::new())
.await
.expect_err("still fails");
assert_eq!(server.received_requests().await.unwrap().len(), 5);
}
// --- document uploads ---------------------------------------------------
use invoiceninja_sdk::FileUpload;
fn pdf(name: &str) -> FileUpload {
FileUpload::new(name, b"%PDF-1.4 fake".to_vec()).mime("application/pdf")
}
fn body_text(request: &wiremock::Request) -> String {
String::from_utf8_lossy(&request.body).into_owned()
}
async fn upload_ok_server(route: &str) -> MockServer {
let server = MockServer::start().await;
Mock::given(path(route))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "abc" } })))
.mount(&server)
.await;
server
}
/// The route is registered as PUT, but PHP does not parse multipart on a PUT,
/// so the files would never arrive. The working form is POST with a
/// `_method=PUT` field, which Laravel turns back into a PUT before routing.
#[tokio::test]
async fn upload_is_a_post_that_spoofs_put_with_documents_as_an_array() {
let server = upload_ok_server("/clients/abc/upload").await;
let updated = client(&server)
.upload_client("abc", vec![pdf("receipt.pdf")], None)
.await
.expect("upload should succeed");
assert_eq!(updated.id.as_deref(), Some("abc"), "returns the entity");
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(
sent.method.as_str(),
"POST",
"a real PUT would drop the files"
);
let content_type = sent.headers.get("content-type").unwrap().to_str().unwrap();
assert!(
content_type.starts_with("multipart/form-data; boundary="),
"got {content_type}"
);
assert_eq!(
sent.headers.get("x-api-token").unwrap().to_str().unwrap(),
"test-token"
);
let body = body_text(sent);
assert!(body.contains("name=\"_method\"\r\n\r\nPUT"), "body: {body}");
assert!(
body.contains("name=\"documents[]\"; filename=\"receipt.pdf\""),
"body: {body}"
);
assert!(body.contains("Content-Type: application/pdf"));
assert!(
body.contains("%PDF-1.4 fake"),
"file bytes must be in the body"
);
}
/// Laravel's `boolean` rule accepts only true, false, 0, 1, '0' and '1' under
/// strict comparison, so the string "true" fails validation.
#[tokio::test]
async fn is_public_is_sent_as_one_or_zero_never_true_false() {
for (flag, expected) in [
(Some(true), Some("1")),
(Some(false), Some("0")),
(None, None),
] {
let server = upload_ok_server("/invoices/abc/upload").await;
client(&server)
.upload_invoice("abc", vec![pdf("a.pdf")], flag)
.await
.expect("upload should succeed");
let body = body_text(&server.received_requests().await.unwrap()[0]);
match expected {
Some(value) => assert!(
body.contains(&format!("name=\"is_public\"\r\n\r\n{value}\r\n")),
"is_public={flag:?} should send {value}, body: {body}"
),
None => assert!(
!body.contains("is_public"),
"an unset flag must be omitted, not defaulted, body: {body}"
),
}
assert!(!body.contains("true") && !body.contains("false"));
}
}
#[tokio::test]
async fn several_files_become_several_document_parts() {
let server = upload_ok_server("/expenses/abc/upload").await;
client(&server)
.upload_expense("abc", vec![pdf("one.pdf"), pdf("two.pdf")], None)
.await
.expect("upload should succeed");
let body = body_text(&server.received_requests().await.unwrap()[0]);
assert_eq!(body.matches("name=\"documents[]\"").count(), 2);
assert!(body.contains("filename=\"one.pdf\"") && body.contains("filename=\"two.pdf\""));
}
/// An upload with nothing in it is answered with a success that changed
/// nothing; that is caught locally instead.
#[tokio::test]
async fn invalid_uploads_are_rejected_before_any_request() {
let server = upload_ok_server("/clients/abc/upload").await;
let c = client(&server);
let none = c.upload_client("abc", vec![], None).await.unwrap_err();
assert!(matches!(none, Error::InvalidUpload(_)), "got {none:?}");
let nameless = c
.upload_client("abc", vec![FileUpload::new(" ", b"x".to_vec())], None)
.await
.unwrap_err();
assert!(
matches!(nameless, Error::InvalidUpload(_)),
"got {nameless:?}"
);
let bad_mime = c
.upload_client(
"abc",
vec![FileUpload::new("a.txt", b"x".to_vec()).mime("not a mime")],
None,
)
.await
.unwrap_err();
assert!(
matches!(bad_mime, Error::InvalidUpload(_)),
"got {bad_mime:?}"
);
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn upload_ids_get_the_same_path_protection() {
let server = upload_ok_server("/clients/x/upload").await;
let err = client(&server)
.upload_client("..", vec![pdf("a.pdf")], None)
.await
.unwrap_err();
assert!(matches!(err, Error::InvalidPathSegment(_)), "got {err:?}");
assert!(server.received_requests().await.unwrap().is_empty());
}
/// An upload ADDS a document, so it is not idempotent whatever the HTTP verb.
/// A replay after a lost response would attach the file twice. This holds
/// only because reqwest's multipart body cannot be cloned, which nothing in
/// the types enforces, so it is pinned here for 5xx and 429 alike.
#[tokio::test]
async fn uploads_are_never_replayed() {
for status in [500u16, 503, 429] {
let server = MockServer::start().await;
Mock::given(path("/clients/abc/upload"))
.respond_with(ResponseTemplate::new(status))
.mount(&server)
.await;
let err = client(&server)
.with_retry_policy(quick_retry())
.upload_client("abc", vec![pdf("a.pdf")], None)
.await
.expect_err("the failure should surface");
assert!(
matches!(err, Error::Api { status: s, .. } if s == status),
"got {err:?}"
);
assert_eq!(
server.received_requests().await.unwrap().len(),
1,
"a {status} on an upload must not be retried"
);
}
}
#[tokio::test]
async fn upload_validation_errors_keep_the_field_map() {
let server = MockServer::start().await;
Mock::given(path("/clients/abc/upload"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"message": "The given data was invalid.",
"errors": { "documents.0": ["The documents.0 must be a file of type: pdf."] }
})))
.mount(&server)
.await;
let err = client(&server)
.upload_client("abc", vec![pdf("a.exe")], None)
.await
.unwrap_err();
match err {
Error::Api { status, errors, .. } => {
assert_eq!(status, 422);
assert!(errors.contains_key("documents.0"));
}
other => panic!("expected Error::Api, got {other:?}"),
}
}
#[test]
fn file_upload_debug_never_prints_the_contents() {
let file = FileUpload::new("secret.pdf", b"CONFIDENTIAL-CONTENTS".to_vec());
let rendered = format!("{file:?}");
assert!(!rendered.contains("CONFIDENTIAL"), "got {rendered}");
assert!(rendered.contains("secret.pdf") && rendered.contains("21"));
}
/// The sixteen methods come from one macro, so a typo in a path or a wrong
/// resource name would only show up here.
#[tokio::test]
async fn every_upload_method_targets_its_own_route() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": {} })))
.mount(&server)
.await;
let c = client(&server);
let f = || vec![pdf("a.pdf")];
c.upload_client("i", f(), None).await.unwrap();
c.upload_company("i", f(), None).await.unwrap();
c.upload_credit("i", f(), None).await.unwrap();
c.upload_expense("i", f(), None).await.unwrap();
c.upload_group_setting("i", f(), None).await.unwrap();
c.upload_invoice("i", f(), None).await.unwrap();
c.upload_payment("i", f(), None).await.unwrap();
c.upload_product("i", f(), None).await.unwrap();
c.upload_project("i", f(), None).await.unwrap();
c.upload_purchase_order("i", f(), None).await.unwrap();
c.upload_quote("i", f(), None).await.unwrap();
c.upload_recurring_expense("i", f(), None).await.unwrap();
c.upload_recurring_invoice("i", f(), None).await.unwrap();
c.upload_recurring_quote("i", f(), None).await.unwrap();
c.upload_task("i", f(), None).await.unwrap();
c.upload_vendor("i", f(), None).await.unwrap();
let paths: Vec<String> = server
.received_requests()
.await
.unwrap()
.iter()
.map(|r| r.url.path().to_string())
.collect();
assert_eq!(
paths,
[
"clients",
"companies",
"credits",
"expenses",
"group_settings",
"invoices",
"payments",
"products",
"projects",
"purchase_orders",
"quotes",
"recurring_expenses",
"recurring_invoices",
"recurring_quotes",
"tasks",
"vendors",
]
.map(|resource| format!("/{resource}/i/upload"))
);
}
// --- transport error diagnosability -------------------------------------
/// `reqwest`'s own message stops at "error sending request for url (...)".
/// The reason is in the source chain, and was invisible to anyone who just
/// printed the error.
#[tokio::test]
async fn transport_errors_say_why_the_request_failed() {
// Port 1 is reserved and nothing listens on it.
let client = InvoiceNinjaClient::new("http://127.0.0.1:1/api/v1", "t")
.unwrap()
.with_retry_policy(RetryPolicy::none());
let err = client
.list_clients(&ListParams::new())
.await
.expect_err("nothing is listening");
assert!(matches!(err, Error::Http(_)), "got {err:?}");
let rendered = err.to_string().to_lowercase();
assert!(
rendered.contains("refused"),
"the cause should appear in the message, got: {rendered}"
);
}
// --- per-resource extras ------------------------------------------------
/// Mounts one route answering 200 with `body`, and returns the server.
async fn route_server(verb: &str, route: &str, body: serde_json::Value) -> MockServer {
let server = MockServer::start().await;
Mock::given(method(verb))
.and(path(route))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.expect(1)
.mount(&server)
.await;
server
}
/// Generates one test per extra. Each mounts the route the upstream router
/// registers, calls the method, and checks it decoded the shape the
/// controller returns. `route_server` expects exactly one request, so a call
/// that hit a different path or verb fails when the server drops.
macro_rules! extra_test {
($name:ident, $verb:literal, $route:literal, $response:expr,
|$c:ident| $call:expr, |$got:ident| $check:expr) => {
#[tokio::test]
async fn $name() {
let server = route_server($verb, $route, $response).await;
let $c = client(&server);
let $got = $call.await.expect("the extra should succeed");
$check;
}
};
}
fn message() -> serde_json::Value {
json!({ "message": "ok" })
}
fn entity() -> serde_json::Value {
json!({ "data": { "id": "abc" } })
}
extra_test!(
retry_webhook_posts_to_retry_and_returns_the_webhook,
"POST",
"/webhooks/abc/retry",
entity(),
|c| c.retry_webhook("abc", &json!({ "entity": "invoice", "entity_id": "i1" })),
|hook| assert_eq!(hook.id.as_deref(), Some("abc"))
);
extra_test!(
sort_tasks_returns_the_message,
"POST",
"/tasks/sort",
message(),
|c| c.sort_tasks(&json!({ "status_ids": ["s1"], "task_ids": { "s1": ["t1"] } })),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
set_default_design_returns_the_message,
"POST",
"/designs/set/default",
message(),
|c| c.set_default_design(&json!({ "design_id": "d1", "entity": "invoice" })),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
set_default_client_gateway_token_returns_the_token,
"POST",
"/client_gateway_tokens/abc/setAsDefault",
json!({ "data": { "id": "abc", "is_default": true } }),
|c| c.set_default_client_gateway_token("abc"),
|token| assert!(token.is_default)
);
extra_test!(
test_company_gateway_returns_the_message,
"POST",
"/company_gateways/abc/test",
message(),
|c| c.test_company_gateway("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
clone_company_gateway_returns_the_copy,
"POST",
"/company_gateways/abc/clone",
entity(),
|c| c.clone_company_gateway("abc"),
|copy| assert_eq!(copy.id.as_deref(), Some("abc"))
);
extra_test!(
import_company_gateway_customers_returns_the_message,
"POST",
"/company_gateways/abc/import_customers",
message(),
|c| c.import_company_gateway_customers("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
remove_bank_account_returns_the_integration,
"POST",
"/bank_integrations/remove_account/acc1",
entity(),
|c| c.remove_bank_account("acc1"),
|got| assert_eq!(got.id.as_deref(), Some("abc"))
);
extra_test!(
fetch_bank_transactions_returns_the_message,
"POST",
"/bank_integrations/get_transactions/acc1",
message(),
|c| c.fetch_bank_transactions("acc1"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
match_bank_transactions_returns_the_matched_rows,
"POST",
"/bank_transactions/match",
json!({ "data": [{ "id": "bt1" }] }),
|c| c.match_bank_transactions(&json!({ "transactions": [] })),
|got| assert_eq!(got.len(), 1)
);
extra_test!(
update_client_tax_data_returns_the_client,
"POST",
"/clients/abc/updateTaxData",
entity(),
|c| c.update_client_tax_data("abc"),
|got| assert_eq!(got.id.as_deref(), Some("abc"))
);
extra_test!(
client_show_settings_returns_raw_json,
"POST",
"/clients/abc/show_settings",
json!({ "currency_id": "1" }),
|c| c.client_show_settings("abc"),
|got| assert_eq!(got["currency_id"], "1")
);
extra_test!(
client_peppol_discovery_returns_raw_json,
"POST",
"/clients/abc/peppol_discovery",
json!({ "found": false }),
|c| c.client_peppol_discovery("abc"),
|got| assert_eq!(got["found"], false)
);
extra_test!(
check_client_vat_returns_the_message,
"POST",
"/clients/abc/check_vat",
message(),
|c| c.check_client_vat("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
client_vat_status_is_a_get,
"GET",
"/clients/abc/vat_status",
message(),
|c| c.client_vat_status("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
client_documents_returns_one_page_of_documents,
"POST",
"/clients/abc/documents",
json!({ "data": [{ "id": "d1" }] }),
|c| c.client_documents("abc", &json!({ "filter": "x" })),
|got| assert_eq!(got.len(), 1)
);
/// The body a caller passes must reach the server, not just the route.
#[tokio::test]
async fn retry_webhook_sends_the_callers_body() {
let server = route_server("POST", "/webhooks/abc/retry", entity()).await;
client(&server)
.retry_webhook("abc", &json!({ "entity": "invoice", "entity_id": "i1" }))
.await
.unwrap();
let sent: serde_json::Value = server.received_requests().await.unwrap()[0]
.body_json()
.unwrap();
assert_eq!(sent, json!({ "entity": "invoice", "entity_id": "i1" }));
}
/// The no-body triggers send `{}` with a JSON content type rather than an
/// empty body, which is what Laravel's request parsing is happy with.
#[tokio::test]
async fn no_body_triggers_send_an_empty_json_object() {
let server = route_server("POST", "/company_gateways/abc/test", message()).await;
client(&server).test_company_gateway("abc").await.unwrap();
let request = &server.received_requests().await.unwrap()[0];
assert_eq!(request.body_json::<serde_json::Value>().unwrap(), json!({}));
assert_eq!(
request.headers.get("content-type").unwrap(),
"application/json"
);
}
#[tokio::test]
async fn subscription_steps_decode_a_list_or_a_keyed_object_and_check_discards_the_body() {
let s = route_server(
"GET",
"/subscriptions/steps",
json!([
{ "id": "auth.login-or-register", "dependencies": [] },
{ "id": "payment_method", "dependencies": ["auth.login-or-register"] }
]),
)
.await;
let steps = client(&s).subscription_steps().await.unwrap();
assert_eq!(steps.len(), 2);
assert_eq!(steps[1].dependencies, ["auth.login-or-register"]);
// What a real 5.13.50 server sends: an object keyed by PHP class name.
let s = route_server(
"GET",
"/subscriptions/steps",
json!({
"App\\Livewire\\Login": { "id": "auth.login", "dependencies": [] },
"App\\Livewire\\Pay": { "id": "payment_method", "dependencies": ["auth.login"] }
}),
)
.await;
let mut steps = client(&s).subscription_steps().await.unwrap();
steps.sort_by(|a, b| a.id.cmp(&b.id));
assert_eq!(steps[0].id, "auth.login");
assert_eq!(steps[1].dependencies, ["auth.login"]);
let s = route_server("POST", "/subscriptions/steps/check", json!([])).await;
client(&s)
.check_subscription_steps(&json!({ "steps": "a,b" }))
.await
.unwrap();
}
#[tokio::test]
async fn refresh_bank_accounts_ignores_the_meaningless_body() {
let s = route_server("POST", "/bank_integrations/refresh_accounts", json!({})).await;
client(&s).refresh_bank_accounts().await.unwrap();
}
#[tokio::test]
async fn extras_validate_ids_and_surface_errors() {
let server = MockServer::start().await;
let c = client(&server);
// A `/` in an id stays inside one segment instead of retargeting the call.
let _ = c.check_client_vat("a/b").await;
let seen = server.received_requests().await.unwrap();
assert_eq!(seen[0].url.path(), "/clients/a%2Fb/check_vat");
assert!(matches!(
c.check_client_vat("..").await,
Err(Error::InvalidPathSegment(_))
));
assert!(matches!(
c.remove_bank_account("").await,
Err(Error::InvalidPathSegment(_))
));
assert!(matches!(
c.test_company_gateway("..").await,
Err(Error::InvalidPathSegment(_))
));
Mock::given(method("POST"))
.and(path("/subscriptions/steps/check"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"message": "invalid",
"errors": { "steps": ["bad order"] }
})))
.mount(&server)
.await;
let err = c
.check_subscription_steps(&json!({ "steps": "b,a" }))
.await
.expect_err("an invalid step list is a 422");
assert!(
matches!(err, Error::Api { status, .. } if status == 422),
"{err:?}"
);
}
/// These are all `POST`s, and some start work on the server, so a 5xx must
/// not be replayed.
#[tokio::test]
async fn extras_are_not_replayed_on_5xx() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/company_gateways/abc/import_customers"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
client(&server)
.with_retry_policy(quick_retry())
.import_company_gateway_customers("abc")
.await
.expect_err("a failed trigger must not be retried");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
// --- password-protected actions -----------------------------------------
extra_test!(
purge_client_returns_the_message,
"POST",
"/clients/abc/purge",
message(),
|c| c.purge_client("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
merge_clients_names_the_survivor_first_and_returns_it,
"POST",
"/clients/keep/drop/merge",
json!({ "data": { "id": "keep" } }),
|c| c.merge_clients("keep", "drop"),
|got| assert_eq!(got.id.as_deref(), Some("keep"))
);
extra_test!(
merge_vendors_names_the_survivor_first_and_returns_it,
"POST",
"/vendors/keep/drop/merge",
json!({ "data": { "id": "keep" } }),
|c| c.merge_vendors("keep", "drop"),
|got| assert_eq!(got.id.as_deref(), Some("keep"))
);
extra_test!(
purge_company_returns_the_message,
"POST",
"/companies/purge/abc",
message(),
|c| c.purge_company("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
purge_company_save_settings_returns_the_message,
"POST",
"/companies/purge_save_settings/abc",
message(),
|c| c.purge_company_save_settings("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
invite_user_returns_the_message,
"POST",
"/users/abc/invite",
message(),
|c| c.invite_user("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
new_user_template_is_a_get_returning_a_blank_user,
"GET",
"/users/create",
entity(),
|c| c.new_user_template(),
|got| assert_eq!(got.id.as_deref(), Some("abc"))
);
/// User purge answers 204 with no body, which must not be treated as a decode
/// failure.
#[tokio::test]
async fn purge_user_accepts_an_empty_204() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/users/abc/purge"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
client(&server).purge_user("abc").await.unwrap();
}
/// Both ids in a merge go through the same path protection, so neither can
/// retarget the request.
#[tokio::test]
async fn merge_validates_both_ids() {
let server = MockServer::start().await;
let c = client(&server);
assert!(matches!(
c.merge_clients("..", "x").await,
Err(Error::InvalidPathSegment(_))
));
assert!(matches!(
c.merge_clients("x", "").await,
Err(Error::InvalidPathSegment(_))
));
assert!(matches!(
c.merge_vendors("x", "..").await,
Err(Error::InvalidPathSegment(_))
));
let _ = c.merge_clients("a/b", "c?d").await;
let seen = server.received_requests().await.unwrap();
assert_eq!(seen.len(), 1, "only the escaped call may reach the server");
assert_eq!(seen[0].url.path(), "/clients/a%2Fb/c%3Fd/merge");
}
/// A destructive POST that may have been applied must never be replayed.
#[tokio::test]
async fn destructive_actions_are_not_replayed_on_5xx() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let c = client(&server).with_retry_policy(quick_retry());
c.purge_client("abc").await.expect_err("500");
c.merge_clients("a", "b").await.expect_err("500");
c.purge_company("abc").await.expect_err("500");
c.purge_user("abc").await.expect_err("500");
assert_eq!(server.received_requests().await.unwrap().len(), 4);
}
/// The password reaches the server on these routes, and without one the
/// header is simply absent.
#[tokio::test]
async fn the_api_password_is_sent_on_protected_actions() {
let server = route_server("POST", "/clients/abc/purge", message()).await;
client(&server)
.with_api_password("hunter2")
.unwrap()
.purge_client("abc")
.await
.unwrap();
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(sent.headers.get("X-API-PASSWORD").unwrap(), "hunter2");
assert!(sent.headers.get("X-API-PASSWORD-BASE64").is_none());
}
/// A password with a control character cannot be a header value. It used to be
/// rejected with a misleading `InvalidToken`; it now goes out base64 encoded,
/// under the header the server's middleware decodes. The cases cover each
/// padding form (`==`, `=`, none).
#[tokio::test]
async fn a_password_with_a_control_character_goes_out_base64_encoded() {
for (password, expected) in [("\n", "Cg=="), ("a\n", "YQo="), ("a\nb", "YQpi")] {
let server = route_server("POST", "/clients/abc/purge", message()).await;
client(&server)
.with_api_password(password)
.expect("any password is accepted")
.purge_client("abc")
.await
.unwrap();
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(
sent.headers.get("X-API-PASSWORD-BASE64").unwrap(),
expected,
"password {password:?}"
);
assert!(
sent.headers.get("X-API-PASSWORD").is_none(),
"the plain header must not also be sent"
);
}
}
/// Non-ASCII text is a valid header value, so those passwords keep going out
/// as `X-API-PASSWORD` exactly as before.
#[tokio::test]
async fn a_non_ascii_password_still_goes_out_as_the_plain_header() {
let server = route_server("POST", "/clients/abc/purge", message()).await;
client(&server)
.with_api_password("pässwörd")
.unwrap()
.purge_client("abc")
.await
.unwrap();
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(
sent.headers.get("X-API-PASSWORD").unwrap().as_bytes(),
"pässwörd".as_bytes()
);
assert!(sent.headers.get("X-API-PASSWORD-BASE64").is_none());
}
/// The base64 fallback is as secret as the password itself, so it must be
/// redacted from `Debug` too.
#[tokio::test]
async fn debug_output_redacts_a_base64_password_too() {
let c = InvoiceNinjaClient::new("http://localhost/api/v1", "tok")
.unwrap()
.with_api_password("S3CRETpw\nX")
.unwrap();
let shown = format!("{c:?}");
assert!(!shown.contains("S3CRET"), "{shown}");
assert!(!shown.contains("UzNDUkVUcHcK"), "{shown}");
assert!(shown.contains("Sensitive"), "{shown}");
}
// --- activities, email history, previews --------------------------------
extra_test!(
list_activities_decodes_the_transformer_shape,
"GET",
"/activities",
json!({ "data": [{
"id": "a1", "activity_type_id": "10", "client_id": "c1", "invoice_id": "",
"is_system": false, "notes": "", "ip": "127.0.0.1",
"created_at": 1792869488, "updated_at": 1792869488
}] }),
|c| c.list_activities(&ListParams::new()),
|got| {
assert_eq!(got.len(), 1);
assert_eq!(got[0].activity_type_id, "10");
assert_eq!(got[0].created_at, Some(1792869488));
}
);
/// The feed is a different shape: numeric type, labelled references.
#[tokio::test]
async fn activity_feed_asks_for_reactv2_and_decodes_labelled_references() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/activities"))
.and(query_param("reactv2", "1"))
.and(query_param("rows", "5"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": [{
"id": "7", "hashed_id": "h7", "activity_type_id": 10, "notes": "", "ip": "",
"created_at": 1792869488,
"user": { "label": "Ann", "hashed_id": "u1" },
"invoice": { "label": "0001", "hashed_id": "i1" },
"payment_amount": "5.00"
}] })))
.expect(1)
.mount(&server)
.await;
let feed = client(&server).activity_feed(Some(5)).await.unwrap();
assert_eq!(feed[0].activity_type_id, 10);
assert_eq!(feed[0].invoice.as_ref().unwrap().label, "0001");
assert!(feed[0].client.is_none());
assert_eq!(feed[0].extra["payment_amount"], "5.00");
}
#[tokio::test]
async fn activity_feed_without_rows_sends_no_rows_parameter() {
let server = route_server("GET", "/activities", json!({ "data": [] })).await;
client(&server).activity_feed(None).await.unwrap();
let url = &server.received_requests().await.unwrap()[0].url;
assert!(
!url.query_pairs().any(|(k, _)| k == "rows"),
"the server picks its own default, got {url}"
);
}
/// The server tests for the key, not its value, so `rows: null` would count
/// as given and `take(null)` would drop the limit.
#[tokio::test]
async fn entity_activity_omits_rows_unless_given() {
for (rows, expected) in [
(None, json!({ "entity": "client", "entity_id": "c1" })),
(
Some(3),
json!({ "entity": "client", "entity_id": "c1", "rows": 3 }),
),
] {
let server = route_server("POST", "/activities/entity", json!({ "data": [] })).await;
client(&server)
.entity_activity("client", "c1", rows)
.await
.unwrap();
let sent: serde_json::Value = server.received_requests().await.unwrap()[0]
.body_json()
.unwrap();
assert_eq!(sent, expected);
}
}
extra_test!(
add_activity_note_returns_the_activity,
"POST",
"/activities/notes",
json!({ "data": { "id": "a9", "notes": "called them" } }),
|c| c.add_activity_note("invoices", "i1", "called them"),
|got| assert_eq!(got.notes, "called them")
);
#[tokio::test]
async fn download_activity_entity_returns_the_pdf_bytes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/activities/download_entity/a1"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4".to_vec()))
.expect(1)
.mount(&server)
.await;
assert_eq!(
client(&server)
.download_activity_entity("a1")
.await
.unwrap(),
b"%PDF-1.4"
);
}
#[tokio::test]
async fn a_missing_activity_backup_is_an_api_error_not_a_decode_failure() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(404)
.set_body_json(json!({ "message": "No backup exists", "errors": {} })),
)
.mount(&server)
.await;
let err = client(&server)
.download_activity_entity("a1")
.await
.expect_err("404");
assert!(
matches!(err, Error::Api { status, .. } if status == 404),
"{err:?}"
);
}
extra_test!(
client_email_history_decodes_a_bare_array,
"POST",
"/emails/clientHistory/c1",
json!([{ "entity": "invoice", "events": [{ "event": "delivered" }] }]),
|c| c.client_email_history("c1"),
|got| assert_eq!(got[0]["events"][0]["event"], "delivered")
);
extra_test!(
entity_email_history_sends_entity_and_id,
"POST",
"/emails/entityHistory",
json!([]),
|c| c.entity_email_history("invoice", "i1"),
|got| assert!(got.is_empty())
);
/// Every preview returns raw bytes (a PDF), never decodes JSON, and is
/// routed to its own path.
#[tokio::test]
async fn previews_return_bytes_from_their_own_routes() {
let routes = [
"/preview",
"/live_preview",
"/live_design",
"/preview/purchase_order",
"/live_preview/purchase_order",
];
for route in routes {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(route))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4".to_vec()))
.expect(1)
.mount(&server)
.await;
let c = client(&server);
let body = json!({ "entity": "invoice", "entity_id": "-1" });
let got = match route {
"/preview" => c.preview(&body).await,
"/live_preview" => c.live_preview(&body).await,
"/live_design" => c.live_design(&body).await,
"/preview/purchase_order" => c.preview_purchase_order(&body).await,
_ => c.live_preview_purchase_order(&body).await,
};
assert_eq!(got.unwrap(), b"%PDF-1.4", "{route}");
}
}
#[tokio::test]
async fn preview_html_asks_for_html_and_returns_text() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/preview"))
.and(query_param("html", "true"))
.respond_with(ResponseTemplate::new(200).set_body_string("<html>x</html>"))
.expect(1)
.mount(&server)
.await;
let html = client(&server)
.preview_html(&json!({ "entity": "invoice", "entity_id": "-1" }))
.await
.unwrap();
assert_eq!(html, "<html>x</html>");
}
/// Previews can be rejected with a JSON validation error rather than a PDF.
#[tokio::test]
async fn a_rejected_preview_keeps_the_validation_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"message": "invalid data access",
"errors": { "design.design.body": ["bad twig"] }
})))
.mount(&server)
.await;
let err = client(&server).preview(&json!({})).await.expect_err("422");
assert!(
matches!(&err, Error::Api { errors, .. } if errors.contains_key("design.design.body")),
"{err:?}"
);
}
/// Adding a note appends every time, so a lost response must not be replayed.
#[tokio::test]
async fn notes_are_not_replayed_on_5xx() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
client(&server)
.with_retry_policy(quick_retry())
.add_activity_note("invoices", "i1", "x")
.await
.expect_err("500");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
// --- invitation-key downloads -------------------------------------------
/// Mounts one GET route answering 200 with raw bytes. Expects exactly one
/// request, so a call that built a different path fails when the server drops.
async fn raw_route_server(route: &str, body: &'static [u8]) -> MockServer {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(route))
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.to_vec()))
.expect(1)
.mount(&server)
.await;
server
}
/// One test per invitation-key download. The route each mounts is the one
/// `routes/api.php` registers — note the singular entity segment, which is
/// what these would get wrong if the plural CRUD path were reused.
macro_rules! invitation_download_test {
($name:ident, $route:literal, $body:literal, |$c:ident| $call:expr) => {
#[tokio::test]
async fn $name() {
let server = raw_route_server($route, $body).await;
let $c = client(&server);
let got = $call.await.expect("the download should succeed");
assert_eq!(got, $body);
}
};
}
invitation_download_test!(
download_invoice_by_invitation_key_hits_the_singular_route,
"/invoice/key1/download",
b"%PDF-1.4",
|c| c.download_invoice_by_invitation_key("key1")
);
invitation_download_test!(
download_e_invoice_by_invitation_key_hits_the_singular_route,
"/invoice/key1/download_e_invoice",
b"<?xml version=\"1.0\"?>",
|c| c.download_e_invoice_by_invitation_key("key1")
);
invitation_download_test!(
download_quote_by_invitation_key_hits_the_singular_route,
"/quote/key1/download",
b"%PDF-1.4",
|c| c.download_quote_by_invitation_key("key1")
);
invitation_download_test!(
download_e_quote_by_invitation_key_hits_the_singular_route,
"/quote/key1/download_e_quote",
b"<?xml version=\"1.0\"?>",
|c| c.download_e_quote_by_invitation_key("key1")
);
invitation_download_test!(
download_credit_by_invitation_key_hits_the_singular_route,
"/credit/key1/download",
b"%PDF-1.4",
|c| c.download_credit_by_invitation_key("key1")
);
invitation_download_test!(
download_e_credit_by_invitation_key_hits_the_singular_route,
"/credit/key1/download_e_credit",
b"<?xml version=\"1.0\"?>",
|c| c.download_e_credit_by_invitation_key("key1")
);
invitation_download_test!(
download_purchase_order_by_invitation_key_hits_the_singular_route,
"/purchase_order/key1/download",
b"%PDF-1.4",
|c| c.download_purchase_order_by_invitation_key("key1")
);
invitation_download_test!(
download_e_purchase_order_by_invitation_key_hits_the_singular_route,
"/purchase_order/key1/download_e_purchase_order",
b"<?xml version=\"1.0\"?>",
|c| c.download_e_purchase_order_by_invitation_key("key1")
);
/// These are authenticated like any other call, which is the whole reason
/// they fit the client at all. A token-less variant would need a separate
/// entry point.
#[tokio::test]
async fn an_invitation_download_still_sends_the_api_token() {
let server = raw_route_server("/invoice/key1/download", b"%PDF-1.4").await;
client(&server)
.download_invoice_by_invitation_key("key1")
.await
.unwrap();
let headers = &server.received_requests().await.unwrap()[0].headers;
assert_eq!(headers["X-Api-Token"], "test-token");
}
/// An unknown key is a `400` carrying `no record found`, not a `404`: the
/// controllers return that directly rather than raising
/// `ModelNotFoundException`.
#[tokio::test]
async fn an_unknown_invitation_key_is_a_400_not_a_404() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/invoice/gone/download"))
.respond_with(
ResponseTemplate::new(400).set_body_json(json!({ "message": "no record found" })),
)
.mount(&server)
.await;
let err = client(&server)
.download_invoice_by_invitation_key("gone")
.await
.expect_err("an unknown key should fail");
assert!(
matches!(&err, Error::Api { status, message, .. }
if status.as_u16() == 400 && message == "no record found"),
"{err:?}"
);
}
/// `invoice//download` is a different route, so an empty key must not reach
/// the server.
#[tokio::test]
async fn an_empty_invitation_key_is_refused_before_the_request() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4".to_vec()))
.mount(&server)
.await;
let err = client(&server)
.download_invoice_by_invitation_key("")
.await
.expect_err("an empty key must not be sent");
assert!(matches!(err, Error::InvalidPathSegment(_)), "got {err:?}");
assert!(server.received_requests().await.unwrap().is_empty());
}
// --- import and export --------------------------------------------------
#[tokio::test]
async fn export_posts_and_returns_the_collection_url() {
let server = route_server(
"POST",
"/export",
json!({ "message": "Processing", "url": "https://x.test/protected_download/abc" }),
)
.await;
let job = client(&server)
.export(&json!({ "send_email": false }))
.await
.expect("export should be accepted");
assert_eq!(job.message, "Processing");
assert_eq!(job.url, "https://x.test/protected_download/abc");
}
#[tokio::test]
async fn preimport_sends_each_csv_under_its_entity_type() {
let server = MockServer::start().await;
Mock::given(path("/preimport"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"hash": "h1",
"mappings": {
"client": { "available": { "name": "Name" }, "headers": [["name"]], "hints": [0] }
}
})))
.mount(&server)
.await;
let preview = client(&server)
.preimport(vec![
("client".to_string(), pdf("clients.csv")),
("invoice".to_string(), pdf("invoices.csv")),
])
.await
.expect("preimport should succeed");
assert_eq!(preview.hash, "h1");
assert!(preview.mappings.contains_key("client"));
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(sent.method.as_str(), "POST");
let body = body_text(sent);
// Keyed by entity type, not `documents[]` — a different field name from
// the `/{id}/upload` routes.
assert!(
body.contains("name=\"files[client]\"; filename=\"clients.csv\""),
"body: {body}"
);
assert!(
body.contains("name=\"files[invoice]\"; filename=\"invoices.csv\""),
"body: {body}"
);
assert!(!body.contains("_method"), "preimport is a real POST");
}
#[tokio::test]
async fn preimport_with_no_files_is_refused_before_the_request() {
let server = MockServer::start().await;
Mock::given(path("/preimport"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "hash": "h1" })))
.mount(&server)
.await;
let err = client(&server)
.preimport(vec![])
.await
.expect_err("an empty import must not be sent");
assert!(matches!(err, Error::InvalidUpload(_)), "got {err:?}");
assert!(server.received_requests().await.unwrap().is_empty());
}
/// The second phase is ordinary JSON, not multipart: the hash stands in for
/// the files, so there is nothing to encode as a form.
#[tokio::test]
async fn import_sends_json_rather_than_a_form() {
let server = route_server("POST", "/import", json!({ "message": "Import started" })).await;
let got = client(&server)
.import(&json!({ "hash": "h1", "column_map": { "client": { "mapping": {} } } }))
.await
.expect("import should be accepted");
assert_eq!(got.message, "Import started");
let sent = &server.received_requests().await.unwrap()[0];
let content_type = sent.headers.get("content-type").unwrap().to_str().unwrap();
assert_eq!(content_type, "application/json");
assert_eq!(
sent.body_json::<serde_json::Value>().unwrap()["hash"],
"h1",
"the hash is what stands in for the upload"
);
}
#[tokio::test]
async fn import_json_sends_one_file_named_files() {
let server = route_server(
"POST",
"/import_json",
json!({ "message": "Processing", "success": true }),
)
.await;
let got = client(&server)
.import_json(pdf("backup.zip"))
.await
.expect("import_json should be accepted");
assert_eq!(got.message, "Processing");
assert_eq!(
got.extra["success"], true,
"the success flag is kept in extra"
);
let body = body_text(&server.received_requests().await.unwrap()[0]);
assert!(
body.contains("name=\"files\"; filename=\"backup.zip\""),
"singular `files`, not `files[]`: body: {body}"
);
}
#[tokio::test]
async fn upload_edocument_is_a_post_that_spoofs_put() {
let server = route_server(
"POST",
"/edocument/upload",
json!({ "message": "Processing...." }),
)
.await;
let got = client(&server)
.upload_edocument(vec![pdf("einvoice.xml")])
.await
.expect("edocument upload should be accepted");
assert_eq!(got.message, "Processing....");
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(
sent.method.as_str(),
"POST",
"the route is a PUT, but a real PUT drops the files"
);
let body = body_text(sent);
assert!(body.contains("name=\"_method\"\r\n\r\nPUT"), "body: {body}");
assert!(
body.contains("name=\"documents[]\"; filename=\"einvoice.xml\""),
"body: {body}"
);
}
#[tokio::test]
async fn edocument_uploads_are_never_replayed() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
client(&server)
.with_retry_policy(quick_retry())
.upload_edocument(vec![pdf("einvoice.xml")])
.await
.expect_err("500");
assert_eq!(
server.received_requests().await.unwrap().len(),
1,
"a multipart body is not cloneable, so it gets one attempt"
);
}
// --- e-invoicing --------------------------------------------------------
fn discovered() -> serde_json::Value {
json!({ "discovered": true })
}
extra_test!(
validate_einvoice_posts_to_validate_entity,
"POST",
"/einvoice/validateEntity",
json!({ "passes": true, "invoices": [], "clients": [] }),
|c| c.validate_einvoice(&json!({ "entity": "invoices", "entity_id": "i1" })),
|got| assert_eq!(got["passes"], true)
);
/// A failed check is a 422, so it arrives as an error rather than an `Ok`
/// carrying the failures. Pinned because it is surprising.
#[tokio::test]
async fn a_failing_einvoice_check_is_an_error_not_a_result() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/einvoice/validateEntity"))
.respond_with(
ResponseTemplate::new(422).set_body_json(json!({ "passes": false, "invoices": ["x"] })),
)
.mount(&server)
.await;
let err = client(&server)
.validate_einvoice(&json!({ "entity": "invoices" }))
.await
.expect_err("a failed validation is a 422");
assert!(
matches!(&err, Error::Api { status, .. } if status.as_u16() == 422),
"{err:?}"
);
}
extra_test!(
einvoice_configurations_posts_the_settings,
"POST",
"/einvoice/configurations",
json!({}),
|c| c.einvoice_configurations(&json!({ "payment_means": [] })),
|got| assert!(got.is_object())
);
extra_test!(
einvoice_quota_reads_the_remaining_count,
"GET",
"/einvoice/quota",
json!({ "quota": 42 }),
|c| c.einvoice_quota(),
|got| assert_eq!(got["quota"], 42)
);
extra_test!(
einvoice_delivery_map_reads_the_static_map,
"GET",
"/einvoice/delivery_map",
json!({ "DE": { "routable": true } }),
|c| c.einvoice_delivery_map(),
|got| assert_eq!(got["DE"]["routable"], true)
);
extra_test!(
peppol_discovery_posts_the_identifier,
"POST",
"/einvoice/peppol/discovery",
discovered(),
|c| c.peppol_discovery(&json!({ "identifier": "0088:123" })),
|got| assert_eq!(got["discovered"], true)
);
extra_test!(
peppol_legal_entity_is_a_post_despite_being_a_read,
"POST",
"/einvoice/peppol/legal_entity",
json!({ "id": 7, "party_name": "Acme" }),
|c| c.peppol_legal_entity(&json!({})),
|got| assert_eq!(got["id"], 7)
);
extra_test!(
peppol_disconnect_posts_and_tolerates_an_empty_array,
"POST",
"/einvoice/peppol/disconnect",
json!([]),
|c| c.peppol_disconnect(&json!({})),
|got| assert!(got.is_array())
);
extra_test!(
peppol_update_legal_entity_is_a_put,
"PUT",
"/einvoice/peppol/update",
json!([]),
|c| c.peppol_update_legal_entity(&json!({ "party_name": "Acme" })),
|got| assert!(got.is_array())
);
extra_test!(
peppol_add_legal_identifier_returns_the_ok_message,
"POST",
"/einvoice/peppol/add_additional_legal_identifier",
message(),
|c| c.peppol_add_legal_identifier(&json!({ "identifier": "0088:1" })),
|got| assert_eq!(got.message, "ok")
);
// A `DELETE` that carries a body, which no other method in the crate does.
extra_test!(
peppol_remove_legal_identifier_is_a_delete_with_a_body,
"DELETE",
"/einvoice/peppol/remove_additional_legal_identifier",
json!([]),
|c| c.peppol_remove_legal_identifier(&json!({ "identifier": "0088:1" })),
|got| assert!(got.is_array())
);
extra_test!(
peppol_retry_send_returns_the_queued_message,
"POST",
"/einvoice/peppol/send",
json!({ "message": "trying...." }),
|c| c.peppol_retry_send(&json!({ "entity_id": "i1" })),
|got| assert_eq!(got.message, "trying....")
);
extra_test!(
peppol_sg_c5_activate_posts_to_the_nested_path,
"POST",
"/einvoice/peppol/sg/c5/activate",
message(),
|c| c.peppol_sg_c5_activate(&json!({})),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
peppol_sg_c5_deactivate_posts_to_the_nested_path,
"POST",
"/einvoice/peppol/sg/c5/deactivate",
message(),
|c| c.peppol_sg_c5_deactivate(&json!({})),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
peppol_sg_c5_cancel_posts_to_the_nested_path,
"POST",
"/einvoice/peppol/sg/c5/cancel",
message(),
|c| c.peppol_sg_c5_cancel(&json!({})),
|got| assert_eq!(got.message, "ok")
);
/// `setup` answers either a URL to send the user to, or a bare 204 when the
/// registration completed outright. Both are successes, so the 204 is `None`
/// rather than a decode error.
#[tokio::test]
async fn peppol_setup_maps_a_204_to_none_and_a_body_to_some() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/einvoice/peppol/setup"))
.respond_with(ResponseTemplate::new(204))
.up_to_n_times(1)
.mount(&server)
.await;
assert_eq!(
client(&server).peppol_setup(&json!({})).await.unwrap(),
None,
"an empty 204 is a completed setup, not a failure"
);
let server = route_server(
"POST",
"/einvoice/peppol/setup",
json!({ "corppass_url": "https://x.test/corppass" }),
)
.await;
let got = client(&server).peppol_setup(&json!({})).await.unwrap();
assert_eq!(
got.unwrap()["corppass_url"],
"https://x.test/corppass",
"a body means a further authorisation step"
);
}
/// The token rotation answers 204 and keeps the new token server-side.
#[tokio::test]
async fn update_einvoice_token_discards_the_empty_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/einvoice/token/update"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
client(&server)
.update_einvoice_token(&json!({}))
.await
.expect("a 204 is a success");
}
#[tokio::test]
async fn einvoice_health_check_accepts_an_empty_object() {
let server = route_server("GET", "/einvoice/health_check", json!({})).await;
client(&server)
.einvoice_health_check()
.await
.expect("an empty object is a pass");
}
// --- quickbooks, stripe, calendar, bank feeds ---------------------------
/// The three 204 routes. A JSON decode attempt on an empty body would fail,
/// so these must go through the discarding helper.
#[tokio::test]
async fn the_quickbooks_204_routes_discard_their_bodies() {
for (route, call) in ["sync", "action", "disconnect"].iter().zip(0..3) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(format!("/quickbooks/{route}")))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let c = client(&server);
let body = json!({});
let result = match call {
0 => c.quickbooks_sync(&body).await,
1 => c.quickbooks_action(&body).await,
_ => c.quickbooks_disconnect(&body).await,
};
result.unwrap_or_else(|e| panic!("quickbooks/{route} should succeed: {e:?}"));
}
}
extra_test!(
quickbooks_reconnect_url_returns_the_one_time_link,
"POST",
"/quickbooks/reconnect_url",
json!({ "reconnect_url": "https://x.test/r", "requires_reconnect": true }),
|c| c.quickbooks_reconnect_url(),
|got| assert_eq!(got["reconnect_url"], "https://x.test/r")
);
extra_test!(
stripe_update_payment_methods_queues_the_refresh,
"POST",
"/stripe/update_payment_methods",
json!({ "message": "Processing" }),
|c| c.stripe_update_payment_methods(),
|got| assert_eq!(got.message, "Processing")
);
extra_test!(
stripe_import_customers_queues_the_import,
"POST",
"/stripe/import_customers",
json!({ "message": "Processing" }),
|c| c.stripe_import_customers(),
|got| assert_eq!(got.message, "Processing")
);
extra_test!(
stripe_verify_passes_the_driver_response_through,
"POST",
"/stripe/verify",
json!({ "verified": true }),
|c| c.stripe_verify(),
|got| assert_eq!(got["verified"], true)
);
extra_test!(
stripe_disconnect_escapes_the_gateway_id,
"POST",
"/stripe/disconnect/cg1",
json!({ "disconnected": true }),
|c| c.stripe_disconnect("cg1"),
|got| assert_eq!(got["disconnected"], true)
);
/// The stripe routes are password-protected, so the header has to reach them.
#[tokio::test]
async fn the_stripe_routes_send_the_api_password() {
let server = route_server(
"POST",
"/stripe/import_customers",
json!({ "message": "Processing" }),
)
.await;
client(&server)
.with_api_password("secret")
.unwrap()
.stripe_import_customers()
.await
.unwrap();
let headers = &server.received_requests().await.unwrap()[0].headers;
assert_eq!(headers["X-API-PASSWORD"], "secret");
}
extra_test!(
calendar_connection_unwraps_the_data_envelope,
"GET",
"/calendar_connection",
json!({ "data": { "provider": "google" } }),
|c| c.calendar_connection(),
|got| assert_eq!(got["provider"], "google")
);
extra_test!(
complete_calendar_connection_escapes_the_provider,
"POST",
"/calendar_connection/google/complete",
json!({ "data": { "provider": "google" } }),
|c| c.complete_calendar_connection("google", &json!({ "code": "x" })),
|got| assert_eq!(got["provider"], "google")
);
extra_test!(
calendar_connection_calendars_lists_the_available_ones,
"GET",
"/calendar_connection/calendars",
json!({ "data": { "calendars": [{ "id": "c1" }] } }),
|c| c.calendar_connection_calendars(),
|got| assert_eq!(got["calendars"][0]["id"], "c1")
);
extra_test!(
update_calendar_connection_calendars_is_a_put,
"PUT",
"/calendar_connection/calendars",
json!({ "data": { "calendar_connection": { "id": "x" } } }),
|c| c.update_calendar_connection_calendars(&json!({ "calendar_ids": ["c1"] })),
|got| assert_eq!(got["calendar_connection"]["id"], "x")
);
#[tokio::test]
async fn calendar_connection_events_sends_both_dates_as_query_params() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/calendar_connection/events"))
.and(query_param("from", "2026-01-01"))
.and(query_param("to", "2026-01-31"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({ "data": { "events": [{ "id": "e1" }] } })),
)
.expect(1)
.mount(&server)
.await;
let got = client(&server)
.calendar_connection_events("2026-01-01", "2026-01-31")
.await
.expect("events should decode");
assert_eq!(got["events"][0]["id"], "e1");
}
#[tokio::test]
async fn delete_calendar_connection_discards_the_null_payload() {
let server = route_server(
"DELETE",
"/calendar_connection",
json!({ "data": { "calendar_connection": null } }),
)
.await;
client(&server)
.delete_calendar_connection()
.await
.expect("the disconnect should succeed");
}
extra_test!(
yodlee_account_status_escapes_the_account_number,
"POST",
"/yodlee/status/acc%2F1",
json!({ "account_status": "ok" }),
|c| c.yodlee_account_status("acc/1"),
|got| assert_eq!(got["account_status"], "ok")
);
extra_test!(
nordigen_institutions_returns_the_bare_array,
"GET",
"/nordigen/institutions",
json!([{ "id": "BANK_X" }]),
|c| c.nordigen_institutions(),
|got| assert_eq!(got[0]["id"], "BANK_X")
);