Files
invoiceninja-sdk/tests/decode_tolerance.rs
T
awkawbandClaude Sonnet 5 1a8e4aad26
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
Add logout, passkey management, and a standalone login
The "auth routes" open question in GOALS.md conflated four unrelated
things once the upstream routes and controllers were checked: logout and
the passkey settings routes sit behind the same token_auth middleware as
everything else this crate wraps, login needs a credential exchange since
there's no token yet, refresh/refresh_react turned out to be the bulk
sync-dump endpoint already parked as a separate open question, and
connected_account is Gmail OAuth linking that belongs with Integrations.

Adds logout, list_passkeys, delete_passkey (passkey registration is left
out — it needs a WebAuthn ceremony a browser has to drive), and
InvoiceNinjaClient::login as an associated function returning a lean
LoginResponse that types only is_owner/is_admin/token and leaves the
server's much larger nested payload in extra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 14:38:48 -04:00

133 lines
3.6 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,
LoginCompanyUser,
LoginResponse,
Passkey,
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"
);
}