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>
233 lines
6.7 KiB
Rust
233 lines
6.7 KiB
Rust
//! 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
|
|
}
|