diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..37fc1cf --- /dev/null +++ b/.github/workflows/smoke.yml @@ -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 }} diff --git a/examples/smoke_test.rs b/examples/smoke_test.rs index b213d4c..b0f595f 100644 --- a/examples/smoke_test.rs +++ b/examples/smoke_test.rs @@ -16,8 +16,17 @@ //! 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 +//! The read tier also fetches the same rows raw and fails if a typed row lost +//! 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 //! purge needs the account password), and it may fire webhooks. //! @@ -28,9 +37,12 @@ use std::fmt::Display; use std::process::ExitCode; 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 serde_json::json; +use serde::Serialize; +use serde_json::{json, Value}; #[derive(Default)] struct Report { @@ -67,13 +79,86 @@ fn describe(error: &Error) -> 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)), +/// Plain GETs for the raw JSON, so a typed result can be compared with what +/// the server actually sent. Decoding successfully proves little: a model +/// that quietly dropped a field would still decode. +struct Raw { + http: reqwest::Client, + base: String, + token: String, +} + +impl Raw { + async fn rows(&self, path: &str) -> Option> { + 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) { + 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] @@ -82,6 +167,11 @@ async fn main() -> ExitCode { let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN"); 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) { Ok(client) => client, Err(e) => { @@ -94,19 +184,34 @@ async fn main() -> ExitCode { let mut report = Report::default(); 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") { - Ok(allowed) if allowed == host => { + // Empty counts as unset: CI passes the variable through even when blank. + 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})"); 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!( "\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); @@ -124,9 +229,25 @@ fn host_of(base_url: &str) -> String { .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); + // 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 { Ok(()) => report.pass("ping", "authenticated"), 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)), } + 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_invoices", client.list_invoices(&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_documents", client.list_documents(&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 // 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; 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. match client.delete_client(&id).await { @@ -374,8 +540,8 @@ async fn upload_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &st .map(Vec::len) }; - // The wire format is the least-verified part: POST + `_method=PUT`, read - // from the framework source rather than observed. + // POST + `_method=PUT`: read from the framework source, then observed + // working on 5.13.50. match client .upload_client(id, vec![text("first")], Some(false)) .await @@ -444,3 +610,247 @@ async fn round_trip_checks( "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)), + } +}