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:
@@ -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
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user