Files
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

228 lines
7.6 KiB
Rust

//! Decodes fixtures captured from a real server.
//!
//! `tests/fixtures/*.json` are written by `examples/capture_shapes.rs` and
//! hold only key names and JSON types (strings are `""`, numbers `0`, bools
//! `false`). Mock responses written from the upstream source have been wrong
//! about the real thing before, so these pin the shapes a server actually
//! sent. To refresh them, seed a disposable instance with
//! `examples/seed_sample_data.rs` and re-run the capture.
//!
//! Three properties are checked: every fixture decodes into its model
//! (goal 2), a decode followed by a re-serialise loses no key (goal 1), and no
//! fixture contains a real value, so a careless capture cannot leak data.
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use invoiceninja_sdk::models::*;
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
fn dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
fn load(name: &str) -> Value {
let path = dir().join(format!("{name}.json"));
let text = fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
/// The captured row of a list fixture.
fn row(name: &str) -> Value {
load(name)["data"][0].clone()
}
/// Key paths present in `raw` but absent from `out`. Arrays are compared
/// through their first element, which is all a fixture keeps.
fn lost_keys(raw: &Value, out: &Value, prefix: &str, into: &mut Vec<String>) {
match (raw, out) {
(Value::Object(raw), Value::Object(out)) => {
for (key, raw_value) in raw {
match out.get(key) {
Some(out_value) => {
lost_keys(raw_value, out_value, &format!("{prefix}.{key}"), into)
}
None => into.push(format!("{prefix}.{key}")),
}
}
}
(Value::Array(raw), Value::Array(out)) => {
if let (Some(raw), Some(out)) = (raw.first(), out.first()) {
lost_keys(raw, out, &format!("{prefix}[]"), into);
}
}
_ => {}
}
}
/// Decodes `raw` as `T`, re-serialises it, and returns problems.
fn check<T: DeserializeOwned + Serialize>(name: &str, raw: &Value) -> Vec<String> {
let typed: T = match serde_json::from_value(raw.clone()) {
Ok(typed) => typed,
Err(e) => return vec![format!("{name}: does not decode: {e}")],
};
let out = serde_json::to_value(&typed).expect("serialises");
let mut lost = Vec::new();
lost_keys(raw, &out, name, &mut lost);
lost.into_iter()
.filter(|path| !OMITTED_WHEN_EMPTY.contains(&path.as_str()))
.map(|path| format!("{path}: key lost on a decode/re-serialise"))
.collect()
}
/// Fields that are deliberately left out of a request when empty, because the
/// server rejects the empty value (`""` becomes `NULL` on a non-null column,
/// or fails an `integer` rule). A fixture holds `""` placeholders, so these
/// show up as absent after a re-serialise. Adding to this list is a decision.
const OMITTED_WHEN_EMPTY: &[&str] = &[
"locations.country_id",
"purchase_orders.status_id",
"webhooks.format",
];
/// One fixture per `(file, type)`; the macro also records which files it
/// covered so a new capture cannot sit untested.
macro_rules! list_fixtures {
($($file:literal => $ty:ty),+ $(,)?) => {
const COVERED_LISTS: &[&str] = &[$($file),+];
fn check_lists() -> Vec<String> {
let mut problems = Vec::new();
$( problems.extend(check::<$ty>($file, &row($file))); )+
problems
}
};
}
list_fixtures! {
"activities" => Activity,
"clients" => Client,
"companies" => Company,
"credits" => Credit,
"designs" => Design,
"documents" => Document,
"expense_categories" => ExpenseCategory,
"expenses" => Expense,
"group_settings" => GroupSetting,
"invoices" => Invoice,
"locations" => Location,
"payment_terms" => PaymentTerm,
"payments" => Payment,
"products" => Product,
"projects" => Project,
"purchase_orders" => PurchaseOrder,
"quotes" => Quote,
"recurring_expenses" => RecurringExpense,
"recurring_invoices" => RecurringInvoice,
"system_logs" => SystemLog,
"tags" => Tag,
"task_statuses" => TaskStatus,
"tasks" => Task,
"tax_rates" => TaxRate,
"tokens" => CompanyToken,
"users" => User,
"vendors" => Vendor,
"webhooks" => Webhook,
}
/// Fixtures that are not a one-row list.
const COVERED_OTHER: &[&str] = &[
"statics",
"health_check",
"subscription_steps",
"activity_feed",
];
#[test]
fn every_real_row_decodes_and_loses_no_key() {
let problems = check_lists();
assert!(
problems.is_empty(),
"{} problem(s):\n{}",
problems.len(),
problems.join("\n")
);
}
#[test]
fn bare_endpoint_fixtures_decode() {
let mut problems = Vec::new();
problems.extend(check::<Statics>("statics", &load("statics")));
problems.extend(check::<HealthCheck>("health_check", &load("health_check")));
problems.extend(check::<ActivityFeedEntry>(
"activity_feed",
&load("activity_feed")["data"][0],
));
// Keyed by class name on a real server; see `subscription_steps`.
let steps: Result<BTreeMap<String, SubscriptionStep>, _> =
serde_json::from_value(load("subscription_steps"));
match steps {
Ok(steps) => assert!(!steps.is_empty(), "the captured steps object is empty"),
Err(e) => problems.push(format!("subscription_steps: {e}")),
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
#[test]
fn every_fixture_file_is_covered_by_a_test() {
let mut on_disk: Vec<String> = fs::read_dir(dir())
.expect("tests/fixtures exists")
.filter_map(|entry| {
let path = entry.ok()?.path();
(path.extension()? == "json").then(|| path.file_stem()?.to_str().map(String::from))?
})
.collect();
on_disk.sort();
let mut covered: Vec<String> = COVERED_LISTS
.iter()
.chain(COVERED_OTHER)
.map(|s| s.to_string())
.collect();
covered.sort();
assert_eq!(
on_disk, covered,
"a captured fixture has no test (or a test has no fixture)"
);
}
/// The capture tool replaces every value; this refuses to pass if any
/// survived, so real data cannot be committed by accident.
#[test]
fn fixtures_hold_shapes_and_never_values() {
fn walk(value: &Value, at: &str, bad: &mut Vec<String>) {
match value {
Value::String(s) if !s.is_empty() => bad.push(format!("{at}: string value")),
Value::Number(n) if n.as_f64() != Some(0.0) => bad.push(format!("{at}: number value")),
Value::Bool(true) => bad.push(format!("{at}: true")),
Value::Array(items) => {
assert!(items.len() <= 1, "{at}: a fixture keeps one array element");
items.iter().for_each(|v| walk(v, &format!("{at}[]"), bad));
}
Value::Object(map) => map
.iter()
.for_each(|(k, v)| walk(v, &format!("{at}.{k}"), bad)),
_ => {}
}
}
let mut bad = Vec::new();
for entry in fs::read_dir(dir()).expect("tests/fixtures exists") {
let path = entry.unwrap().path();
if path.extension().is_some_and(|e| e == "json") {
let name = path.file_stem().unwrap().to_string_lossy().to_string();
walk(&load(&name), &name, &mut bad);
}
}
assert!(
bad.is_empty(),
"fixtures contain values:\n{}",
bad.join("\n")
);
}