Add multipart document uploads for the 16 resources that have the route

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>
This commit is contained in:
2026-10-24 14:26:41 -04:00
co-authored by Claude Opus 5
parent 355cf08324
commit e4f2373b53
12 changed files with 608 additions and 32 deletions
+274
View File
@@ -1176,3 +1176,277 @@ async fn no_budget_means_only_max_retries_bounds_the_call() {
.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"))
);
}