Files
invoiceninja-sdk/tests/decode_tolerance.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

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 invoiceninja_sdk::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"
);
}