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