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>
130 lines
3.5 KiB
Rust
130 lines
3.5 KiB
Rust
//! Goal 2, checked mechanically: every model must absorb a field that is
|
|
//! `null` or absent.
|
|
//!
|
|
//! Invoice Ninja returns `null` for many nominally-string fields and omits
|
|
//! others, so a model that fails on either takes the whole response down with
|
|
//! it. Rather than trusting each field to carry the right serde attribute,
|
|
//! this serialises `Default` (which emits every non-optional field), then for
|
|
//! each field in turn nulls it and, separately, removes it, and requires the
|
|
//! result to still decode.
|
|
//!
|
|
//! A wrong *type* is deliberately not covered: that is meant to be a hard
|
|
//! error, so it is not something to absorb.
|
|
|
|
use invoiceninjars::models::*;
|
|
use serde::{de::DeserializeOwned, Serialize};
|
|
use serde_json::Value;
|
|
|
|
/// Returns one line per mutation that failed to decode.
|
|
fn failures<T: Serialize + DeserializeOwned + Default>(name: &str) -> Vec<String> {
|
|
let base = serde_json::to_value(T::default()).expect("default serialises");
|
|
let object = base
|
|
.as_object()
|
|
.unwrap_or_else(|| panic!("{name}: default did not serialise to an object"));
|
|
|
|
let mut out = Vec::new();
|
|
for key in object.keys() {
|
|
let mut nulled = base.clone();
|
|
nulled[key] = Value::Null;
|
|
if let Err(e) = serde_json::from_value::<T>(nulled) {
|
|
out.push(format!("{name}.{key} = null: {e}"));
|
|
}
|
|
|
|
let mut removed = base.clone();
|
|
removed.as_object_mut().unwrap().remove(key);
|
|
if let Err(e) = serde_json::from_value::<T>(removed) {
|
|
out.push(format!("{name}.{key} absent: {e}"));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
macro_rules! tolerant {
|
|
($($ty:ty),+ $(,)?) => {{
|
|
let mut all = Vec::new();
|
|
$( all.extend(failures::<$ty>(stringify!($ty))); )+
|
|
all
|
|
}};
|
|
}
|
|
|
|
#[test]
|
|
fn every_model_absorbs_a_null_or_missing_field() {
|
|
let all = tolerant![
|
|
Activity,
|
|
ActivityFeedEntry,
|
|
ActivityRef,
|
|
BankIntegration,
|
|
BankTransaction,
|
|
BankTransactionRule,
|
|
Client,
|
|
ClientContact,
|
|
ClientGatewayToken,
|
|
Company,
|
|
CompanyGateway,
|
|
CompanyToken,
|
|
Design,
|
|
Document,
|
|
Expense,
|
|
ExpenseCategory,
|
|
ExportJob,
|
|
GroupSetting,
|
|
ImportPreview,
|
|
Invoice,
|
|
InvoiceItem,
|
|
Location,
|
|
Payment,
|
|
PaymentInvoice,
|
|
PaymentTerm,
|
|
Product,
|
|
Project,
|
|
PurchaseOrder,
|
|
RecurringExpense,
|
|
RecurringInvoice,
|
|
Subscription,
|
|
SubscriptionStep,
|
|
SystemLog,
|
|
Tag,
|
|
Task,
|
|
TaskScheduler,
|
|
TaskStatus,
|
|
TaxRate,
|
|
User,
|
|
Vendor,
|
|
VendorContact,
|
|
Webhook,
|
|
];
|
|
|
|
assert!(
|
|
all.is_empty(),
|
|
"{} field(s) break decoding on null/absent:\n{}",
|
|
all.len(),
|
|
all.join("\n")
|
|
);
|
|
}
|
|
|
|
/// A guard on the test itself: if `Default` stopped emitting fields, the loop
|
|
/// above would pass vacuously.
|
|
#[test]
|
|
fn the_tolerance_test_actually_exercises_fields() {
|
|
let client = serde_json::to_value(Client::default()).unwrap();
|
|
assert!(
|
|
client.as_object().unwrap().len() > 10,
|
|
"Client::default() should serialise many fields"
|
|
);
|
|
}
|
|
|
|
/// The mutation really does bite: a field without `null_to_default` fails.
|
|
#[test]
|
|
fn a_bare_non_option_field_would_be_caught() {
|
|
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
|
struct Naive {
|
|
#[serde(default)]
|
|
name: String,
|
|
}
|
|
assert_eq!(
|
|
failures::<Naive>("Naive").len(),
|
|
1,
|
|
"only the null case fails"
|
|
);
|
|
}
|