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
|
||||
//! ```
|
||||
//!
|
||||
//! **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<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]
|
||||
@@ -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;
|
||||
}
|
||||
Ok(allowed) => {
|
||||
None => println!("\npassword checks skipped (set IN_API_PASSWORD to enable)"),
|
||||
}
|
||||
}
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user