Extend the smoke test with write and password tiers

The read tier now also fetches each row raw and fails if a typed row lost a
key, which is what catches a field the models drop.

The write tier creates a purchase order, location, webhook and recurring
invoice from bare `Default` models, since those are exactly what broke
against a real server, and exercises the uploads and the client extras. The
password tier merges two throwaway clients and purges the survivor.

Writes stay behind the existing guard: `IN_SMOKE_WRITE_HOST` must equal the
host in `IN_BASE_URL`, so a write only ever reaches a server that was named
explicitly. It prints PASS/FAIL per check and never the contents of a
record. `smoke.yml` runs it on demand only (`workflow_dispatch`), with the
write tier gated on the `write_host` input naming the instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 21:56:31 -04:00
co-authored by Claude Opus 5
parent 46a6ea1322
commit a7d9a2d9d6
2 changed files with 474 additions and 17 deletions
+47
View File
@@ -0,0 +1,47 @@
name: Smoke test
# Runs `examples/smoke_test.rs` against a real Invoice Ninja instance. Manual
# only: it needs an instance the runner can reach and credentials for it, and
# the offline suite in `ci.yml` is what gates merges. This is for catching the
# places where the real server disagrees with the upstream source the mocks
# were written from.
#
# Repository secrets required: IN_BASE_URL (the API root, ending /api/v1) and
# IN_API_TOKEN. Point them at a disposable instance, never at production.
on:
workflow_dispatch:
inputs:
write_host:
description: >-
Leave empty for the read-only tier (GET requests only). To also run
the write tier, type the host of IN_BASE_URL (e.g. `localhost`). It
creates and deletes records, so the instance must be disposable.
required: false
default: ''
type: string
runner:
description: Runner label; use a self-hosted one if the instance is not public.
required: false
default: ubuntu-latest
type: string
permissions:
contents: read
jobs:
smoke:
runs-on: ${{ inputs.runner }}
steps:
- uses: actions/checkout@v4
# Same pin as `ci.yml` and `devVersion` in flake.nix.
- uses: dtolnay/rust-toolchain@1.99.1
- run: cargo run --example smoke_test
env:
IN_BASE_URL: ${{ secrets.IN_BASE_URL }}
IN_API_TOKEN: ${{ secrets.IN_API_TOKEN }}
# The program refuses to write unless this equals the host in
# IN_BASE_URL, so a mistyped host skips the write tier.
IN_SMOKE_WRITE_HOST: ${{ inputs.write_host }}
# Optional. Only used inside the write tier, where it enables the
# password tier (merge two throwaway clients, purge the survivor).
IN_API_PASSWORD: ${{ secrets.IN_API_PASSWORD }}
+427 -17
View File
@@ -16,8 +16,17 @@
//! cargo run --example smoke_test //! cargo run --example smoke_test
//! ``` //! ```
//! //!
//! **Do not point the write tier at a production server.** It creates a //! The read tier also fetches the same rows raw and fails if a typed row lost
//! client, attaches files to it, and deletes it. Even after the delete that //! a key. The write tier creates a client, attaches files to it, creates a
//! purchase order, location, webhook, and recurring invoice from default
//! models, and deletes what it made.
//!
//! With `IN_API_PASSWORD` also set, a password tier runs inside the write
//! tier: it merges two throwaway clients and purges the survivor, which
//! really removes the row. Take the password from the environment, never from
//! a command line or chat.
//!
//! **Do not point the write tier at a production server.** Even after the delete that
//! leaves a gap in the client number sequence and a soft-deleted row (a real //! 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. //! purge needs the account password), and it may fire webhooks.
//! //!
@@ -28,9 +37,12 @@ use std::fmt::Display;
use std::process::ExitCode; use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use invoiceninjars::models::{Client, ListParams}; use invoiceninjars::models::{
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
};
use invoiceninjars::{Error, FileUpload, InvoiceNinjaClient}; use invoiceninjars::{Error, FileUpload, InvoiceNinjaClient};
use serde_json::json; use serde::Serialize;
use serde_json::{json, Value};
#[derive(Default)] #[derive(Default)]
struct Report { struct Report {
@@ -67,13 +79,86 @@ fn describe(error: &Error) -> String {
} }
} }
macro_rules! list { /// Plain GETs for the raw JSON, so a typed result can be compared with what
($report:expr, $name:literal, $call:expr) => { /// the server actually sent. Decoding successfully proves little: a model
match $call.await { /// that quietly dropped a field would still decode.
Ok(rows) => $report.pass($name, format!("{} row(s) decoded", rows.len())), struct Raw {
Err(e) => $report.fail($name, describe(&e)), http: reqwest::Client,
base: String,
token: String,
}
impl Raw {
async fn rows(&self, path: &str) -> Option<Vec<Value>> {
let body: Value = self
.http
.get(format!("{}/{path}?per_page=2", self.base))
.header("X-Api-Token", &self.token)
.header("Accept", "application/json")
.header("X-Requested-With", "XMLHttpRequest")
.send()
.await
.ok()?
.json()
.await
.ok()?;
body.get("data")?.as_array().cloned()
}
}
/// Keys that are left out of a request when empty on purpose (the server
/// rejects the empty value), so an empty one is not a lost key.
const OMITTED_WHEN_EMPTY: &[&str] = &["country_id", "status_id", "format"];
/// Key paths in `raw` that `typed` no longer has.
fn lost_keys(raw: &Value, typed: &Value, prefix: &str, into: &mut Vec<String>) {
match (raw, typed) {
(Value::Object(raw), Value::Object(typed)) => {
for (key, raw_value) in raw {
match typed.get(key) {
Some(typed_value) => {
lost_keys(raw_value, typed_value, &format!("{prefix}.{key}"), into)
}
None if raw_value == "" && OMITTED_WHEN_EMPTY.contains(&key.as_str()) => {}
None => into.push(format!("{prefix}.{key}")),
}
}
} }
(Value::Array(raw), Value::Array(typed)) => {
if let (Some(raw), Some(typed)) = (raw.first(), typed.first()) {
lost_keys(raw, typed, &format!("{prefix}[]"), into);
}
}
_ => {}
}
}
/// Fetches the same rows raw and requires the typed ones to keep every key.
/// Only key names are printed, never values.
async fn key_loss(report: &mut Report, raw: &Raw, path: &str, typed: &[impl Serialize]) {
let Some(raw_rows) = raw.rows(path).await else {
report.fail(
&format!("keys kept: {path}"),
"could not fetch the raw rows",
);
return;
}; };
let mut lost = Vec::new();
for (raw_row, typed_row) in raw_rows.iter().zip(typed) {
let typed_row = serde_json::to_value(typed_row).unwrap_or(Value::Null);
lost_keys(raw_row, &typed_row, path, &mut lost);
}
lost.sort();
lost.dedup();
report.check(
&format!("keys kept: {path}"),
lost.is_empty(),
if lost.is_empty() {
format!("{} row(s), no key lost", raw_rows.len().min(typed.len()))
} else {
format!("lost {}", lost.join(", "))
},
);
} }
#[tokio::main] #[tokio::main]
@@ -82,6 +167,11 @@ async fn main() -> ExitCode {
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN"); let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
let host = host_of(&base_url); let host = host_of(&base_url);
let raw = Raw {
http: reqwest::Client::new(),
base: base_url.trim_end_matches('/').to_string(),
token: token.clone(),
};
let client = match InvoiceNinjaClient::new(&base_url, token) { let client = match InvoiceNinjaClient::new(&base_url, token) {
Ok(client) => client, Ok(client) => client,
Err(e) => { Err(e) => {
@@ -94,19 +184,34 @@ async fn main() -> ExitCode {
let mut report = Report::default(); let mut report = Report::default();
println!("\nread-only checks"); println!("\nread-only checks");
read_checks(&client, &mut report).await; read_checks(&client, &raw, &mut report).await;
match std::env::var("IN_SMOKE_WRITE_HOST") { // Empty counts as unset: CI passes the variable through even when blank.
Ok(allowed) if allowed == host => { match std::env::var("IN_SMOKE_WRITE_HOST")
.ok()
.filter(|v| !v.is_empty())
{
Some(allowed) if allowed == host => {
println!("\nwrite checks (IN_SMOKE_WRITE_HOST matches {host})"); println!("\nwrite checks (IN_SMOKE_WRITE_HOST matches {host})");
write_checks(&client, &mut report).await; write_checks(&client, &mut report).await;
match std::env::var("IN_API_PASSWORD")
.ok()
.filter(|v| !v.is_empty())
{
Some(password) => {
println!("\npassword checks (IN_API_PASSWORD is set)");
password_checks(&client, &mut report, password).await;
}
None => println!("\npassword checks skipped (set IN_API_PASSWORD to enable)"),
}
} }
Ok(allowed) => { Some(allowed) => {
println!( println!(
"\nwrite checks SKIPPED: IN_SMOKE_WRITE_HOST is `{allowed}` but the target is `{host}`" "\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)"), None => println!("\nwrite checks skipped (set IN_SMOKE_WRITE_HOST to enable)"),
} }
println!("\n{} passed, {} failed", report.passed, report.failed); println!("\n{} passed, {} failed", report.passed, report.failed);
@@ -124,9 +229,25 @@ fn host_of(base_url: &str) -> String {
.unwrap_or_default() .unwrap_or_default()
} }
async fn read_checks(client: &InvoiceNinjaClient, report: &mut Report) { async fn read_checks(client: &InvoiceNinjaClient, raw: &Raw, report: &mut Report) {
let small = ListParams::new().per_page(2); let small = ListParams::new().per_page(2);
// Decodes one page, then (if there are rows) checks none of their keys
// were lost. The REST path is the method name without `list_`.
macro_rules! list {
($report:expr, $name:literal, $call:expr) => {
match $call.await {
Ok(rows) => {
$report.pass($name, format!("{} row(s) decoded", rows.len()));
if !rows.is_empty() {
key_loss($report, raw, $name.trim_start_matches("list_"), &rows).await;
}
}
Err(e) => $report.fail($name, describe(&e)),
}
};
}
match client.ping().await { match client.ping().await {
Ok(()) => report.pass("ping", "authenticated"), Ok(()) => report.pass("ping", "authenticated"),
Err(e) => report.fail("ping", describe(&e)), Err(e) => report.fail("ping", describe(&e)),
@@ -170,6 +291,47 @@ async fn read_checks(client: &InvoiceNinjaClient, report: &mut Report) {
Err(e) => report.fail("search", describe(&e)), Err(e) => report.fail("search", describe(&e)),
} }
match client.subscription_steps().await {
Ok(steps) => report.check(
"subscription_steps",
!steps.is_empty() && steps.iter().all(|s| !s.id.is_empty()),
format!("{} step(s) decoded", steps.len()),
),
Err(e) => report.fail("subscription_steps", describe(&e)),
}
match client.activity_feed(Some(5)).await {
Ok(feed) => report.check(
"activity_feed",
feed.iter().all(|e| e.activity_type_id > 0),
format!("{} entr(ies), numeric type ids", feed.len()),
),
Err(e) => report.fail("activity_feed", describe(&e)),
}
// A POST, but it only renders: nothing is stored. Needs an invoice to
// render, so it is skipped on an empty instance.
match client.list_invoices(&small).await {
Ok(rows) => match rows
.first()
.and_then(|i| i.id.clone().map(|id| (id, i.client_id.clone())))
{
Some((id, client_id)) => {
let body = json!({ "entity": "invoice", "entity_id": id, "client_id": client_id });
match client.live_preview(&body).await {
Ok(pdf) => report.check(
"live_preview",
pdf.starts_with(b"%PDF"),
format!("{} byte(s), starts with the PDF magic", pdf.len()),
),
Err(e) => report.fail("live_preview", describe(&e)),
}
}
None => report.pass("live_preview", "skipped: no invoices on this instance"),
},
Err(e) => report.fail("live_preview", describe(&e)),
}
list!(report, "list_clients", client.list_clients(&small)); list!(report, "list_clients", client.list_clients(&small));
list!(report, "list_invoices", client.list_invoices(&small)); list!(report, "list_invoices", client.list_invoices(&small));
list!(report, "list_quotes", client.list_quotes(&small)); list!(report, "list_quotes", client.list_quotes(&small));
@@ -265,6 +427,7 @@ async fn read_checks(client: &InvoiceNinjaClient, report: &mut Report) {
list!(report, "list_system_logs", client.list_system_logs(&small)); list!(report, "list_system_logs", client.list_system_logs(&small));
list!(report, "list_documents", client.list_documents(&small)); list!(report, "list_documents", client.list_documents(&small));
list!(report, "list_users", client.list_users(&small)); list!(report, "list_users", client.list_users(&small));
list!(report, "list_activities", client.list_activities(&small));
// The walk follows `meta.pagination.total_pages`, which only a real server // The walk follows `meta.pagination.total_pages`, which only a real server
// can show we are reading correctly. Compare it to one oversized request. // can show we are reading correctly. Compare it to one oversized request.
@@ -345,6 +508,9 @@ async fn write_checks(client: &InvoiceNinjaClient, report: &mut Report) {
upload_checks(client, report, &id).await; upload_checks(client, report, &id).await;
round_trip_checks(client, report, &id, created.extra.len()).await; round_trip_checks(client, report, &id, created.extra.len()).await;
extras_checks(client, report, &id).await;
creation_checks(client, report, &id).await;
activity_checks(client, report, &id).await;
// Always runs, including after a failed check above. // Always runs, including after a failed check above.
match client.delete_client(&id).await { match client.delete_client(&id).await {
@@ -374,8 +540,8 @@ async fn upload_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &st
.map(Vec::len) .map(Vec::len)
}; };
// The wire format is the least-verified part: POST + `_method=PUT`, read // POST + `_method=PUT`: read from the framework source, then observed
// from the framework source rather than observed. // working on 5.13.50.
match client match client
.upload_client(id, vec![text("first")], Some(false)) .upload_client(id, vec![text("first")], Some(false))
.await .await
@@ -444,3 +610,247 @@ async fn round_trip_checks(
"private_notes round-tripped", "private_notes round-tripped",
); );
} }
/// Per-client extras that only read or reshape data already on the record.
/// `updateTaxData` and `check_vat` are left out: they call external services.
async fn extras_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
match client.client_show_settings(id).await {
Ok(settings) => report.check(
"client_show_settings",
settings.is_object(),
format!("{} setting(s)", settings.as_object().map_or(0, |m| m.len())),
),
Err(e) => report.fail("client_show_settings", describe(&e)),
}
match client.client_vat_status(id).await {
Ok(status) => report.pass(
"client_vat_status",
format!("message of {} char(s)", status.message.len()),
),
Err(e) => report.fail("client_vat_status", describe(&e)),
}
// The two uploads in `upload_checks` should be visible here.
match client.client_documents(id, &json!({})).await {
Ok(docs) => report.check(
"client_documents",
docs.len() == 3,
format!("{} document(s) (expected 3)", docs.len()),
),
Err(e) => report.fail("client_documents", describe(&e)),
}
}
/// Creates from bare `Default` models the records that broke against a real
/// server: an empty `status_id`, `country_id` or `format` was rejected, and a
/// string `frequency_id` failed to decode after the record existed. Each is
/// deleted again straight away.
async fn creation_checks(client: &InvoiceNinjaClient, report: &mut Report, client_id: &str) {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let vendor = match client
.create_vendor(&Vendor {
name: format!("sdk-smoke-vendor-{stamp}"),
..Default::default()
})
.await
{
Ok(vendor) => vendor,
Err(e) => {
report.fail("create_vendor", describe(&e));
return;
}
};
let vendor_id = vendor.id.clone().unwrap_or_default();
match client
.create_purchase_order(&PurchaseOrder {
vendor_id: vendor_id.clone(),
..Default::default()
})
.await
{
Ok(po) => {
report.pass("create_purchase_order (default)", "empty status_id omitted");
if let Some(id) = po.id {
let _ = client.delete_purchase_order(&id).await;
}
}
Err(e) => report.fail("create_purchase_order (default)", describe(&e)),
}
match client
.create_location(&Location {
name: format!("sdk-smoke-location-{stamp}"),
client_id: client_id.to_string(),
..Default::default()
})
.await
{
Ok(location) => {
report.pass("create_location (default)", "empty country_id omitted");
if let Some(id) = location.id {
let _ = client.delete_location(&id).await;
}
}
Err(e) => report.fail("create_location (default)", describe(&e)),
}
match client
.create_webhook(&Webhook {
event_id: "1".into(),
target_url: "https://example.com/sdk-smoke".into(),
..Default::default()
})
.await
{
Ok(hook) => {
report.pass("create_webhook (default)", "empty format omitted");
if let Some(id) = hook.id {
let _ = client.delete_webhook(&id).await;
}
}
Err(e) => report.fail("create_webhook (default)", describe(&e)),
}
// The create succeeds either way; decoding the reply is what failed.
match client
.create_recurring_invoice(&RecurringInvoice {
client_id: client_id.to_string(),
frequency_id: 5,
..Default::default()
})
.await
{
Ok(recurring) => {
report.check(
"create_recurring_invoice",
recurring.frequency_id == 5,
format!("frequency_id decoded as {}", recurring.frequency_id),
);
if let Some(id) = recurring.id {
let _ = client.delete_recurring_invoice(&id).await;
}
}
Err(e) => report.fail("create_recurring_invoice", describe(&e)),
}
if let Err(e) = client.delete_vendor(&vendor_id).await {
report.fail(
"delete_vendor",
format!("{} -- remove the vendor by hand", describe(&e)),
);
}
}
/// Password tier: needs `IN_API_PASSWORD`, and only runs inside the write
/// tier's host guard. Merges two throwaway clients, then purges the survivor,
/// which unlike `delete_client` really removes the row. Users, companies and
/// vendors are left alone: inviting a user sends mail, and a company purge
/// takes data that was not created here.
async fn password_checks(client: &InvoiceNinjaClient, report: &mut Report, password: String) {
let client = match client.clone().with_api_password(password) {
Ok(client) => client,
Err(e) => {
report.fail("with_api_password", describe(&e));
return;
}
};
match client.new_user_template().await {
Ok(user) => report.check(
"new_user_template",
user.id.as_deref().unwrap_or("").is_empty(),
"blank user, nothing stored",
),
Err(e) => report.fail("new_user_template", describe(&e)),
}
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut ids = Vec::new();
for label in ["keep", "drop"] {
match client
.create_client(&Client {
name: format!("sdk-smoke-merge-{label}-{stamp}"),
..Default::default()
})
.await
{
Ok(created) => ids.extend(created.id),
Err(e) => {
report.fail("create_client (merge)", describe(&e));
return;
}
}
}
let [keep, drop] = [ids[0].clone(), ids[1].clone()];
match client.merge_clients(&keep, &drop).await {
Ok(merged) => report.check(
"merge_clients",
merged.id.as_deref() == Some(keep.as_str()),
"the first id is the survivor",
),
Err(e) => report.fail("merge_clients", describe(&e)),
}
match client.purge_client(&keep).await {
Ok(message) => report.pass("purge_client", format!("`{}`", message.message)),
Err(e) => report.fail(
"purge_client",
format!(
"{} -- remove the sdk-smoke-merge clients by hand",
describe(&e)
),
),
}
// A purge is permanent, so the record must now be gone, not just flagged.
match client.get_client(&keep).await {
Err(Error::Api { status, .. }) if status.as_u16() == 404 => {
report.pass("get_client after purge", "404, the row is gone")
}
Err(e) => report.fail("get_client after purge", describe(&e)),
Ok(_) => report.fail("get_client after purge", "still readable after a purge"),
}
}
/// Notes, the per-record feed and email history, against the throwaway
/// client. Nothing here sends mail: the history is read, and is expected to
/// be empty because no mail has gone out for this client.
async fn activity_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
let note = "sdk smoke note";
match client.add_activity_note("clients", id, note).await {
Ok(activity) => report.check(
"add_activity_note",
activity.notes == note && activity.client_id == id,
"note stored against the client",
),
Err(e) => report.fail("add_activity_note", describe(&e)),
}
match client.entity_activity("client", id, Some(10)).await {
Ok(feed) => report.check(
"entity_activity",
feed.iter().any(|e| e.notes == note),
format!("{} entr(ies), including the note", feed.len()),
),
Err(e) => report.fail("entity_activity", describe(&e)),
}
match client.client_email_history(id).await {
Ok(history) => report.check(
"client_email_history",
history.is_empty(),
"no mail sent, so no history",
),
Err(e) => report.fail("client_email_history", describe(&e)),
}
}