Pin the response shapes captured from a real server

Twice now a mock written from the upstream source passed while a real server
disagreed — the system-log timestamps and the `/subscriptions/steps` shape —
and `frequency_id` and several empty-string fields were wrong the same way.
Reading the source harder was not going to find the next one.

`tests/fixtures/*.json` hold the *shapes* of real responses: every key, with
its JSON type, and no values at all. `real_shapes.rs` decodes each one and
checks that a decode plus re-serialise loses no key, so a field the models
silently drop now fails a test. It also refuses a fixture holding any real
value, and refuses a fixture that no test reads.

`seed_sample_data.rs` fills a disposable instance with one row of each kind
and `capture_shapes.rs` captures the shapes back out with GET requests only,
so the set can be refreshed when a divergence turns up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 21:56:17 -04:00
co-authored by Claude Opus 5
parent 83270e8262
commit 46a6ea1322
35 changed files with 3267 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
//! Records the *shape* of real Invoice Ninja responses as test fixtures.
//!
//! Mock servers written from the upstream source have been wrong about the
//! real thing before (system-log timestamps, `/subscriptions/steps`), so the
//! offline suite benefits from fixtures that came from a real server. This
//! writes them without writing any data: every string becomes `""`, every
//! number `0`, every bool `false`, and an array keeps only its first element.
//! What survives is the key names and the JSON types, which is what decoding
//! depends on. `tests/real_shapes.rs` refuses to pass if a fixture holds
//! anything else.
//!
//! GET requests only, so it is safe against any instance, but it still reads
//! real records in memory. Review the diff before committing.
//!
//! ```sh
//! IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
//! cargo run --example capture_shapes
//! ```
//!
//! A resource with no rows on the instance writes nothing and is reported as
//! skipped, since an empty list says nothing about a row's shape. Populate a
//! disposable instance and re-run to capture more.
use std::fs;
use std::path::Path;
use serde_json::{json, Value};
/// Resources captured as a one-row list. The name doubles as the fixture
/// filename.
const LISTS: &[&str] = &[
"activities",
"bank_integrations",
"bank_transaction_rules",
"bank_transactions",
"client_gateway_tokens",
"clients",
"companies",
"company_gateways",
"credits",
"designs",
"documents",
"expense_categories",
"expenses",
"group_settings",
"invoices",
"locations",
"payment_terms",
"payments",
"products",
"projects",
"purchase_orders",
"quotes",
"recurring_expenses",
"recurring_invoices",
"recurring_quotes",
"subscriptions",
"system_logs",
"tags",
"task_schedulers",
"task_statuses",
"tasks",
"tax_rates",
"tokens",
"users",
"vendors",
"webhooks",
];
/// Endpoints with no `data` envelope, captured whole.
const BARE: &[(&str, &str)] = &[
("statics", "statics"),
("health_check", "health_check"),
("subscription_steps", "subscriptions/steps"),
("activity_feed", "activities?rows=1&reactv2=1"),
];
/// Replaces every value with a placeholder of the same JSON type.
fn shape(value: &Value) -> Value {
match value {
Value::Null => Value::Null,
Value::Bool(_) => json!(false),
Value::Number(n) if n.is_f64() => json!(0.0),
Value::Number(_) => json!(0),
Value::String(_) => json!(""),
// Only the first element: a list's rows share a shape, and keeping
// all of them would just be size.
Value::Array(items) => Value::Array(items.first().map(shape).into_iter().collect()),
Value::Object(map) => {
Value::Object(map.iter().map(|(k, v)| (k.clone(), shape(v))).collect())
}
}
}
#[tokio::main]
async fn main() {
let base = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL");
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
let base = base.trim_end_matches('/');
let out = Path::new("tests/fixtures");
fs::create_dir_all(out).expect("create tests/fixtures");
let http = reqwest::Client::new();
let get = |path: String| {
let http = http.clone();
let token = token.clone();
let url = format!("{base}/{path}");
async move {
let response = http
.get(url)
.header("X-Api-Token", token)
.header("Accept", "application/json")
.header("X-Requested-With", "XMLHttpRequest")
.send()
.await
.map_err(|e| e.to_string())?;
let status = response.status();
let body = response.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(format!("HTTP {status}"));
}
serde_json::from_str::<Value>(&body).map_err(|e| e.to_string())
}
};
for name in LISTS {
// Deleted and archived rows included: on a disposable instance they
// are often all there is.
// `system_logs` answers the status filter with a 500, so fall back to
// the plain list when the filtered one fails.
let filtered = format!("{name}?per_page=1&status=active,archived,deleted");
let result = match get(filtered).await {
Ok(body) => Ok(body),
Err(_) => get(format!("{name}?per_page=1")).await,
};
match result {
Ok(body) => match body
.get("data")
.and_then(Value::as_array)
.and_then(|d| d.first())
{
Some(row) => {
write(out, name, &json!({ "data": [shape(row)] }));
println!(" captured {name}");
}
None => println!(" skipped {name}: no rows on this instance"),
},
Err(e) => println!(" FAILED {name}: {e}"),
}
}
for (name, path) in BARE {
match get((*path).to_string()).await {
Ok(body) => {
write(out, name, &shape(&body));
println!(" captured {name}");
}
Err(e) => println!(" FAILED {name}: {e}"),
}
}
}
fn write(dir: &Path, name: &str, value: &Value) {
let mut text = serde_json::to_string_pretty(value).expect("serialise fixture");
text.push('\n');
fs::write(dir.join(format!("{name}.json")), text).expect("write fixture");
}