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:
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Fills a **disposable** Invoice Ninja instance with one record of each
|
||||
//! common kind, so `capture_shapes` has real rows to take shapes from.
|
||||
//!
|
||||
//! ```sh
|
||||
//! IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=http://localhost:8080/api/v1 \
|
||||
//! IN_API_TOKEN=... cargo run --example seed_sample_data
|
||||
//! ```
|
||||
//!
|
||||
//! **Never run this against production.** It creates records, advances
|
||||
//! number sequences, and may fire webhooks. It uses the smoke test's guard:
|
||||
//! `IN_SMOKE_WRITE_HOST` must equal the host in `IN_BASE_URL`. Nothing is
|
||||
//! deleted afterwards; the instance is meant to be thrown away.
|
||||
//!
|
||||
//! Each step is independent: one that fails (a validation rule this script
|
||||
//! did not anticipate) is reported and the rest carry on, with anything that
|
||||
//! needed it skipped.
|
||||
|
||||
use std::future::Future;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use invoiceninjars::{Error, InvoiceNinjaClient};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn model<T: DeserializeOwned>(value: Value) -> T {
|
||||
serde_json::from_value(value).expect("seed body matches the model")
|
||||
}
|
||||
|
||||
fn describe(error: &Error) -> String {
|
||||
match error {
|
||||
Error::Api { errors, .. } if !errors.is_empty() => format!("{error} {errors:?}"),
|
||||
_ => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one creation, reporting the outcome and returning the new id.
|
||||
async fn step<T>(name: &str, call: impl Future<Output = invoiceninjars::Result<T>>) -> Option<T> {
|
||||
match call.await {
|
||||
Ok(created) => {
|
||||
println!(" created {name}");
|
||||
Some(created)
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" FAILED {name}: {}", describe(&e));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let base_url = 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 host = reqwest::Url::parse(&base_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(str::to_string))
|
||||
.unwrap_or_default();
|
||||
if std::env::var("IN_SMOKE_WRITE_HOST").as_deref() != Ok(host.as_str()) {
|
||||
eprintln!("refusing to write: set IN_SMOKE_WRITE_HOST={host} to confirm this instance is disposable");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
let c = InvoiceNinjaClient::new(&base_url, token).expect("client");
|
||||
println!("seeding {host}");
|
||||
|
||||
let line =
|
||||
json!([{ "product_key": "seed-product", "quantity": 1, "cost": 10.0, "notes": "seed" }]);
|
||||
|
||||
let client_id = step(
|
||||
"client",
|
||||
c.create_client(&model(json!({
|
||||
"name": "seed-client",
|
||||
"contacts": [{ "first_name": "Seed", "last_name": "Contact", "email": "seed@example.com" }],
|
||||
}))),
|
||||
)
|
||||
.await
|
||||
.and_then(|x| x.id);
|
||||
let vendor_id = step(
|
||||
"vendor",
|
||||
c.create_vendor(&model(json!({ "name": "seed-vendor" }))),
|
||||
)
|
||||
.await
|
||||
.and_then(|x| x.id);
|
||||
|
||||
step(
|
||||
"product",
|
||||
c.create_product(&model(
|
||||
json!({ "product_key": "seed-product", "price": 10.0 }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"tax_rate",
|
||||
c.create_tax_rate(&model(json!({ "name": "seed-tax", "rate": 5.0 }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"payment_term",
|
||||
c.create_payment_term(&model(json!({ "name": "seed-term", "num_days": 15 }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"expense_category",
|
||||
c.create_expense_category(&model(json!({ "name": "seed-category" }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"tag",
|
||||
c.create_tag(&model(
|
||||
json!({ "name": "seed-tag", "entity_type": "client" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"group_setting",
|
||||
c.create_group_setting(&model(json!({ "name": "seed-group" }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"webhook",
|
||||
c.create_webhook(&model(
|
||||
json!({ "event_id": "1", "target_url": "https://example.com/seed-hook" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"expense",
|
||||
c.create_expense(&model(
|
||||
json!({ "amount": 5.0, "public_notes": "seed", "vendor_id": vendor_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"recurring_expense",
|
||||
c.create_recurring_expense(&model(
|
||||
json!({ "amount": 5.0, "frequency_id": 5, "vendor_id": vendor_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(vendor_id) = &vendor_id {
|
||||
step(
|
||||
"purchase_order",
|
||||
c.create_purchase_order(&model(
|
||||
json!({ "vendor_id": vendor_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let Some(client_id) = client_id else {
|
||||
println!("no client, so the client-dependent records are skipped");
|
||||
return ExitCode::FAILURE;
|
||||
};
|
||||
|
||||
step(
|
||||
"location",
|
||||
c.create_location(&model(
|
||||
json!({ "name": "seed-location", "client_id": client_id, "address1": "1 Seed St" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"project",
|
||||
c.create_project(&model(
|
||||
json!({ "name": "seed-project", "client_id": client_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"task",
|
||||
c.create_task(&model(
|
||||
json!({ "description": "seed task", "client_id": client_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"quote",
|
||||
c.create_quote(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"credit",
|
||||
c.create_credit(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"recurring_invoice",
|
||||
c.create_recurring_invoice(&model(
|
||||
json!({ "client_id": client_id, "frequency_id": 5, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
// Expected to 500 on 5.13.50: the `recurring_quotes` table has no
|
||||
// `design_id` column, so the server's own insert fails. Not an SDK bug.
|
||||
step(
|
||||
"recurring_quote",
|
||||
c.create_recurring_quote(&model(
|
||||
json!({ "client_id": client_id, "frequency_id": 5, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
let invoice = step(
|
||||
"invoice",
|
||||
c.create_invoice(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
if let Some(id) = invoice.and_then(|i| i.id) {
|
||||
// A payment can only be applied to an invoice that has been sent.
|
||||
if step("invoice mark_sent", c.mark_invoice_sent(&id))
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
step(
|
||||
"payment",
|
||||
c.create_payment(&model(json!({
|
||||
"client_id": client_id,
|
||||
"amount": 5.0,
|
||||
"invoices": [{ "invoice_id": id, "amount": 5.0 }],
|
||||
}))),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Reference in New Issue
Block a user