Wrap import, export, and the e-document importer
First piece of the last roadmap item. Five routes: `POST /export`,
`POST /preimport`, `POST /import`, `POST /import_json`, and
`PUT /edocument/upload`.
A CSV import is two calls, and only that form is wrapped. `preimport`
uploads one file per entity type as `files[<entity_type>]` — a different
field name from the `documents[]` the `/{id}/upload` routes take — and
answers with a hash plus the column mapping the server suggests. `import`
then starts the ingest from that hash, as plain JSON: with a hash present
`ImportController::import` never looks at uploaded files.
The route does also accept the files and the mapping together in one
multipart request. That form is left out on purpose. A mapping is a nested
structure, and bracket-encoding nested structures into form fields is the
thing this crate avoids by sending JSON everywhere else — the same reason
`COVERAGE.md` gives for diverging from the PHP SDK's `form_params`.
`upload_edocument` is not a document attachment, which is why it is not an
`upload_*` method and why the roadmap had already split it out:
`ExpenseController::edocument` parses each file and creates *expenses*. It
shares the `_method=PUT` spoof with the upload routes, since it is likewise
registered as a `PUT`, but its reply has no `data` envelope — hence the new
`multipart_unwrapped` helper, which dispatches a form built at the call site
rather than imposing one shape on all four import routes.
Two new models, for the two shapes that are stable and that are the point of
the call: `ExportJob` (the collection URL, which expires in one hour, or
five past 10,000 activities) and `ImportPreview` (the hash). Their
`available`/`headers`/`hints` payloads stay `Value` — those come from each
entity's own `importable()` map and are undocumented, so a concrete type
would be a hard decode error where `Value` is lossless.
All five queue work and answer before it happens, so a 200 means accepted.
Failed rows are reported by email, not in the response, and `import_json`
overwrites the company it is pointed at.
`/migration/start` stays unwrapped and is now listed on its own: it is
driven by the v4 migration tool rather than by an SDK caller.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2365,3 +2365,174 @@ async fn an_empty_invitation_key_is_refused_before_the_request() {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,9 @@ fn every_model_absorbs_a_null_or_missing_field() {
|
||||
Document,
|
||||
Expense,
|
||||
ExpenseCategory,
|
||||
ExportJob,
|
||||
GroupSetting,
|
||||
ImportPreview,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
Location,
|
||||
|
||||
Reference in New Issue
Block a user