The suite runs against mock servers written from the upstream source, so
it cannot catch a place where a real server disagrees with that reading.
`examples/smoke_test.rs` can: it lists every resource, checks the
pagination walk against a single oversized request, probes the per_page
cap, and exercises statics, search and health_check. The upload wire
format (POST plus `_method=PUT`) was read from the framework source
rather than observed, so its write tier is the real verification of it.
It prints PASS/FAIL per check and never the contents of a record.
Writes are guarded. `IN_SMOKE_WRITE_HOST` must equal the host in
`IN_BASE_URL`, so a write can only reach a server that was named
explicitly. `IN_BASE_URL` has no default, unlike the other example,
because defaulting to the hosted app is the wrong failure for a tool
that can write. The write tier leaves a gap in the client number
sequence and a soft-deleted row even after cleanup, so it is documented
as disposable-instance only.
Running it against a dead port exposed a diagnosability gap:
`Error::Http` printed "error sending request for url (...)" and nothing
else. reqwest's Display stops at the outermost layer, and the reason
("Connection refused", a DNS failure, a TLS error) lives in `source()`.
The error now renders its whole cause chain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
447 lines
14 KiB
Rust
447 lines
14 KiB
Rust
//! Smoke test against a real Invoice Ninja instance.
|
|
//!
|
|
//! The rest of the suite runs against mock servers written from the upstream
|
|
//! source, so it cannot catch a place where the real server disagrees with
|
|
//! that reading. This can. It prints PASS/FAIL per check, never the contents
|
|
//! of any record, and exits non-zero if anything failed.
|
|
//!
|
|
//! ```sh
|
|
//! # Read-only: GET requests only.
|
|
//! IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
|
|
//! cargo run --example smoke_test
|
|
//!
|
|
//! # Adds the write tier. Naming the host is the guard: it must equal the
|
|
//! # host in IN_BASE_URL, so a write cannot reach a server you did not name.
|
|
//! IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
|
|
//! cargo run --example smoke_test
|
|
//! ```
|
|
//!
|
|
//! **Do not point the write tier at a production server.** It creates a
|
|
//! client, attaches files to it, and deletes it. Even after the delete that
|
|
//! leaves a gap in the client number sequence and a soft-deleted row (a real
|
|
//! purge needs the account password), and it may fire webhooks.
|
|
//!
|
|
//! `IN_BASE_URL` has no default, unlike the other example, because defaulting
|
|
//! to the hosted app is the wrong failure for a tool that can write.
|
|
|
|
use std::fmt::Display;
|
|
use std::process::ExitCode;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use invoiceninjars::models::{Client, ListParams};
|
|
use invoiceninjars::{Error, FileUpload, InvoiceNinjaClient};
|
|
use serde_json::json;
|
|
|
|
#[derive(Default)]
|
|
struct Report {
|
|
passed: u32,
|
|
failed: u32,
|
|
}
|
|
|
|
impl Report {
|
|
fn pass(&mut self, name: &str, detail: impl Display) {
|
|
println!(" PASS {name:<34} {detail}");
|
|
self.passed += 1;
|
|
}
|
|
|
|
fn fail(&mut self, name: &str, error: impl Display) {
|
|
println!(" FAIL {name:<34} {error}");
|
|
self.failed += 1;
|
|
}
|
|
|
|
fn check(&mut self, name: &str, ok: bool, detail: impl Display) {
|
|
if ok {
|
|
self.pass(name, detail);
|
|
} else {
|
|
self.fail(name, detail);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Renders an error with its per-field validation map, which is usually what
|
|
/// explains a 422.
|
|
fn describe(error: &Error) -> String {
|
|
match error {
|
|
Error::Api { errors, .. } if !errors.is_empty() => format!("{error} {errors:?}"),
|
|
_ => error.to_string(),
|
|
}
|
|
}
|
|
|
|
macro_rules! list {
|
|
($report:expr, $name:literal, $call:expr) => {
|
|
match $call.await {
|
|
Ok(rows) => $report.pass($name, format!("{} row(s) decoded", rows.len())),
|
|
Err(e) => $report.fail($name, describe(&e)),
|
|
}
|
|
};
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> ExitCode {
|
|
let base_url = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL (no default, on purpose)");
|
|
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
|
|
|
let host = host_of(&base_url);
|
|
let client = match InvoiceNinjaClient::new(&base_url, token) {
|
|
Ok(client) => client,
|
|
Err(e) => {
|
|
eprintln!("could not build client: {e}");
|
|
return ExitCode::FAILURE;
|
|
}
|
|
};
|
|
|
|
println!("target: {host}");
|
|
let mut report = Report::default();
|
|
|
|
println!("\nread-only checks");
|
|
read_checks(&client, &mut report).await;
|
|
|
|
match std::env::var("IN_SMOKE_WRITE_HOST") {
|
|
Ok(allowed) if allowed == host => {
|
|
println!("\nwrite checks (IN_SMOKE_WRITE_HOST matches {host})");
|
|
write_checks(&client, &mut report).await;
|
|
}
|
|
Ok(allowed) => {
|
|
println!(
|
|
"\nwrite checks SKIPPED: IN_SMOKE_WRITE_HOST is `{allowed}` but the target is `{host}`"
|
|
);
|
|
}
|
|
Err(_) => println!("\nwrite checks skipped (set IN_SMOKE_WRITE_HOST to enable)"),
|
|
}
|
|
|
|
println!("\n{} passed, {} failed", report.passed, report.failed);
|
|
if report.failed == 0 {
|
|
ExitCode::SUCCESS
|
|
} else {
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
|
|
fn host_of(base_url: &str) -> String {
|
|
reqwest::Url::parse(base_url)
|
|
.ok()
|
|
.and_then(|url| url.host_str().map(str::to_string))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
async fn read_checks(client: &InvoiceNinjaClient, report: &mut Report) {
|
|
let small = ListParams::new().per_page(2);
|
|
|
|
match client.ping().await {
|
|
Ok(()) => report.pass("ping", "authenticated"),
|
|
Err(e) => report.fail("ping", describe(&e)),
|
|
}
|
|
|
|
// Hosted returns a bare message instead of a report; both must decode.
|
|
match client.health_check().await {
|
|
Ok(health) => report.pass(
|
|
"health_check",
|
|
match health.system_health {
|
|
Some(ok) => format!("self-hosted report, system_health={ok}"),
|
|
None => "bare message (no report on this deployment)".to_string(),
|
|
},
|
|
),
|
|
Err(e) => report.fail("health_check", describe(&e)),
|
|
}
|
|
|
|
match client.statics().await {
|
|
Ok(s) => report.check(
|
|
"statics",
|
|
!s.currencies.is_empty() && !s.countries.is_empty(),
|
|
format!(
|
|
"{} currencies, {} countries, {} unmodeled key(s) kept in extra",
|
|
s.currencies.len(),
|
|
s.countries.len(),
|
|
s.extra.len()
|
|
),
|
|
),
|
|
Err(e) => report.fail("statics", describe(&e)),
|
|
}
|
|
|
|
match client.search(&json!({ "search": "a" })).await {
|
|
Ok(results) => report.pass(
|
|
"search",
|
|
format!(
|
|
"{} client hit(s), {} unmodeled bucket(s)",
|
|
results.clients.len(),
|
|
results.extra.len()
|
|
),
|
|
),
|
|
Err(e) => report.fail("search", describe(&e)),
|
|
}
|
|
|
|
list!(report, "list_clients", client.list_clients(&small));
|
|
list!(report, "list_invoices", client.list_invoices(&small));
|
|
list!(report, "list_quotes", client.list_quotes(&small));
|
|
list!(report, "list_credits", client.list_credits(&small));
|
|
list!(report, "list_payments", client.list_payments(&small));
|
|
list!(report, "list_products", client.list_products(&small));
|
|
list!(report, "list_expenses", client.list_expenses(&small));
|
|
list!(report, "list_projects", client.list_projects(&small));
|
|
list!(report, "list_tasks", client.list_tasks(&small));
|
|
list!(report, "list_vendors", client.list_vendors(&small));
|
|
list!(
|
|
report,
|
|
"list_recurring_invoices",
|
|
client.list_recurring_invoices(&small)
|
|
);
|
|
list!(
|
|
report,
|
|
"list_recurring_quotes",
|
|
client.list_recurring_quotes(&small)
|
|
);
|
|
list!(
|
|
report,
|
|
"list_recurring_expenses",
|
|
client.list_recurring_expenses(&small)
|
|
);
|
|
list!(
|
|
report,
|
|
"list_purchase_orders",
|
|
client.list_purchase_orders(&small)
|
|
);
|
|
list!(report, "list_tax_rates", client.list_tax_rates(&small));
|
|
list!(
|
|
report,
|
|
"list_expense_categories",
|
|
client.list_expense_categories(&small)
|
|
);
|
|
list!(
|
|
report,
|
|
"list_task_statuses",
|
|
client.list_task_statuses(&small)
|
|
);
|
|
list!(
|
|
report,
|
|
"list_payment_terms",
|
|
client.list_payment_terms(&small)
|
|
);
|
|
list!(
|
|
report,
|
|
"list_subscriptions",
|
|
client.list_subscriptions(&small)
|
|
);
|
|
list!(
|
|
report,
|
|
"list_bank_transactions",
|
|
client.list_bank_transactions(&small)
|
|
);
|
|
list!(report, "list_webhooks", client.list_webhooks(&small));
|
|
list!(
|
|
report,
|
|
"list_bank_integrations",
|
|
client.list_bank_integrations(&small)
|
|
);
|
|
list!(
|
|
report,
|
|
"list_bank_transaction_rules",
|
|
client.list_bank_transaction_rules(&small)
|
|
);
|
|
list!(
|
|
report,
|
|
"list_company_gateways",
|
|
client.list_company_gateways(&small)
|
|
);
|
|
list!(report, "list_designs", client.list_designs(&small));
|
|
list!(
|
|
report,
|
|
"list_group_settings",
|
|
client.list_group_settings(&small)
|
|
);
|
|
list!(report, "list_locations", client.list_locations(&small));
|
|
list!(report, "list_tags", client.list_tags(&small));
|
|
list!(
|
|
report,
|
|
"list_task_schedulers",
|
|
client.list_task_schedulers(&small)
|
|
);
|
|
list!(report, "list_tokens", client.list_tokens(&small));
|
|
list!(
|
|
report,
|
|
"list_client_gateway_tokens",
|
|
client.list_client_gateway_tokens(&small)
|
|
);
|
|
list!(report, "list_companies", client.list_companies(&small));
|
|
list!(report, "list_system_logs", client.list_system_logs(&small));
|
|
list!(report, "list_documents", client.list_documents(&small));
|
|
list!(report, "list_users", client.list_users(&small));
|
|
|
|
// The walk follows `meta.pagination.total_pages`, which only a real server
|
|
// can show we are reading correctly. Compare it to one oversized request.
|
|
let everything = ListParams::new().per_page(5000);
|
|
match (
|
|
client
|
|
.list_all_clients(&ListParams::new().per_page(2))
|
|
.await,
|
|
client.list_clients(&everything).await,
|
|
) {
|
|
(Ok(walked), Ok(single)) if single.len() < 5000 => report.check(
|
|
"pagination walk == single request",
|
|
walked.len() == single.len(),
|
|
format!(
|
|
"walked {} at per_page=2, single request returned {}",
|
|
walked.len(),
|
|
single.len()
|
|
),
|
|
),
|
|
(Ok(_), Ok(_)) => report.pass("pagination walk", "skipped: 5000+ clients"),
|
|
(Err(e), _) | (_, Err(e)) => report.fail("pagination walk", describe(&e)),
|
|
}
|
|
|
|
// The server caps per_page. Ask for far more than the cap and see how many
|
|
// rows come back, which is the fact the clamp in `get_all_pages` rests on.
|
|
match client
|
|
.list_clients(&ListParams::new().per_page(100_000))
|
|
.await
|
|
{
|
|
Ok(rows) => report.pass(
|
|
"per_page cap",
|
|
format!("asked for 100000, got {} row(s) (cap is 5000)", rows.len()),
|
|
),
|
|
Err(e) => report.fail("per_page cap", describe(&e)),
|
|
}
|
|
|
|
// A single fetch by id, to exercise `get_one` and `seg`.
|
|
match client.list_clients(&small).await {
|
|
Ok(rows) => match rows.first().and_then(|c| c.id.clone()) {
|
|
Some(id) => match client.get_client(&id).await {
|
|
Ok(c) => report.pass(
|
|
"get_client",
|
|
format!("fetched one, {} unmodeled field(s) in extra", c.extra.len()),
|
|
),
|
|
Err(e) => report.fail("get_client", describe(&e)),
|
|
},
|
|
None => report.pass("get_client", "skipped: no clients on this instance"),
|
|
},
|
|
Err(e) => report.fail("get_client", describe(&e)),
|
|
}
|
|
}
|
|
|
|
async fn write_checks(client: &InvoiceNinjaClient, report: &mut Report) {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
let name = format!("sdk-smoke-{nanos}");
|
|
|
|
let created = match client
|
|
.create_client(&Client {
|
|
name: name.clone(),
|
|
..Default::default()
|
|
})
|
|
.await
|
|
{
|
|
Ok(created) => created,
|
|
Err(e) => {
|
|
report.fail("create_client", describe(&e));
|
|
return;
|
|
}
|
|
};
|
|
let Some(id) = created.id.clone() else {
|
|
report.fail("create_client", "response carried no id");
|
|
return;
|
|
};
|
|
report.pass("create_client", format!("created `{name}`"));
|
|
|
|
upload_checks(client, report, &id).await;
|
|
round_trip_checks(client, report, &id, created.extra.len()).await;
|
|
|
|
// Always runs, including after a failed check above.
|
|
match client.delete_client(&id).await {
|
|
Ok(()) => report.pass(
|
|
"delete_client",
|
|
"soft-deleted (a purge needs the account password)",
|
|
),
|
|
Err(e) => report.fail(
|
|
"delete_client",
|
|
format!("{} -- remove `{name}` by hand", describe(&e)),
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn upload_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
|
|
let text = |label: &str| {
|
|
FileUpload::new(
|
|
format!("{label}.txt"),
|
|
format!("smoke {label}").into_bytes(),
|
|
)
|
|
.mime("text/plain")
|
|
};
|
|
let count_documents = |c: &Client| {
|
|
c.extra
|
|
.get("documents")
|
|
.and_then(|d| d.as_array())
|
|
.map(Vec::len)
|
|
};
|
|
|
|
// The wire format is the least-verified part: POST + `_method=PUT`, read
|
|
// from the framework source rather than observed.
|
|
match client
|
|
.upload_client(id, vec![text("first")], Some(false))
|
|
.await
|
|
{
|
|
Ok(c) => report.check(
|
|
"upload_client (1 file, private)",
|
|
count_documents(&c) == Some(1),
|
|
format!("documents on record: {:?}", count_documents(&c)),
|
|
),
|
|
Err(e) => report.fail("upload_client (1 file, private)", describe(&e)),
|
|
}
|
|
|
|
match client
|
|
.upload_client(id, vec![text("second"), text("third")], Some(true))
|
|
.await
|
|
{
|
|
Ok(c) => report.check(
|
|
"upload_client (2 files, public)",
|
|
count_documents(&c) == Some(3),
|
|
format!(
|
|
"documents on record: {:?} (expected 3)",
|
|
count_documents(&c)
|
|
),
|
|
),
|
|
Err(e) => report.fail("upload_client (2 files, public)", describe(&e)),
|
|
}
|
|
}
|
|
|
|
async fn round_trip_checks(
|
|
client: &InvoiceNinjaClient,
|
|
report: &mut Report,
|
|
id: &str,
|
|
extra_keys_at_create: usize,
|
|
) {
|
|
// `get` then `update` must not drop fields the crate does not model.
|
|
let fetched = match client.get_client(id).await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
report.fail("get_client (round trip)", describe(&e));
|
|
return;
|
|
}
|
|
};
|
|
let before = fetched.extra.len();
|
|
|
|
let mut edited = fetched;
|
|
edited.private_notes = "edited by smoke test".into();
|
|
let updated = match client.update_client(id, &edited).await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
report.fail("update_client", describe(&e));
|
|
return;
|
|
}
|
|
};
|
|
|
|
report.check(
|
|
"update_client preserves extra",
|
|
updated.extra.len() >= before.min(extra_keys_at_create),
|
|
format!(
|
|
"extra keys: {extra_keys_at_create} at create, {before} on get, {} after update",
|
|
updated.extra.len()
|
|
),
|
|
);
|
|
report.check(
|
|
"update_client applied the edit",
|
|
updated.private_notes == "edited by smoke test",
|
|
"private_notes round-tripped",
|
|
);
|
|
}
|