First item on the GOALS.md roadmap. `upload_{x}(id, files, is_public)`
attaches files through `/{resource}/{id}/upload` and returns the
refreshed record. Verified against upstream that all 16 controllers
answer with `itemResponse($x->fresh())`.
Three details that look wrong but are not, each checked in the upstream
source rather than taken from the published spec:
- The request is a POST carrying `_method=PUT`. The route is registered
PUT, but PHP does not parse a multipart body on a real PUT, so the
files never arrive. Laravel's kernel calls
`enableHttpMethodParameterOverride()`, turning the spoofed method back
into a PUT before routing. The spec documents POST with `_method=POST`,
which would 405 against these routes.
- `is_public` is sent as 1/0. Laravel's `boolean` rule accepts only
true, false, 0, 1, '0' and '1' under strict comparison, so "true"
fails validation.
- Uploads are never retried. An upload adds a document, so it is not
idempotent whatever its verb, and a replay after a lost response would
attach the file twice. This falls out of reqwest's multipart body being
un-cloneable, which nothing in the types enforces, so a test pins it
for 500, 503 and 429.
An upload with no files, a nameless file or an unparseable MIME type is
refused locally (`Error::InvalidUpload`), since the server answers an
empty upload with a success that changed nothing.
Corrects two earlier claims:
- GOALS.md and COVERAGE.md said uploads would make `create_document`
possible. They do not: `DocumentController::store` is an empty stub,
and an upload is the only way a document is created. There is no
`create_document`.
- The documented method count (265) had been stale since the typed
`statics`/`search`/`health_check` commit, which added three `_as`
methods without updating it. Recounted from the source: 284 = 210 +
18 + 12 + 16 uploads + 28 hand-written.
Also fixes a README example, `let statics: Value = client.statics()`,
that stopped compiling when `statics` lost its type parameter.
`edocument/upload` is left out: it imports an expense from an
e-document rather than attaching a file, so it moves to the
import/export roadmap item.
Enabling `multipart` adds mime, mime_guess and unicase; MSRV stays 1.88,
re-verified by running the suite on it. 101 tests, up from 91.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1453 lines
48 KiB
Rust
1453 lines
48 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 invoiceninjars::{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 invoiceninjars::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 = invoiceninjars::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: invoiceninjars::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 invoiceninjars::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"))
|
|
);
|
|
}
|