Wrap the per-resource extras, activities, email history and previews

Forty-one hand-written endpoints, taking the crate from 284 methods to 325:
the per-resource extras (webhook retry, task sorting, default design,
subscription steps, the company gateway and bank integration actions, and
the client extras), the password-protected destructive actions (client and
company purges, client and vendor merges, user invite and purge), the
activity list, feed, per-entity feed and note, email history, and the six
preview routes.

The password-protected actions send `X-API-PASSWORD`, falling back to
`X-API-PASSWORD-BASE64` when the password holds a control character, which
cannot go in a header value. None of the destructive actions are replayed
on a 5xx, and neither are the notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 21:55:54 -04:00
co-authored by Claude Opus 5
parent bd2d34cae9
commit f616da08db
2 changed files with 1248 additions and 14 deletions
+502 -14
View File
@@ -6,12 +6,13 @@ use serde::{de::DeserializeOwned, Serialize};
use crate::error::{ApiErrorBody, Error, Result}; use crate::error::{ApiErrorBody, Error, Result};
use crate::models::{ use crate::models::{
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client as ClientModel, Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
ClientGatewayToken, Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
ExpenseCategory, GroupSetting, HealthCheck, Invoice, ItemResponse, ListParams, ListResponse, Credit, Design, Document, Expense, ExpenseCategory, GroupSetting, HealthCheck, Invoice,
Location, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense, ItemResponse, ListParams, ListResponse, Location, MessageResponse, Payment, PaymentTerm,
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SystemLog, Tag, Task, Product, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook, SearchResults, Statics, Subscription, SubscriptionStep, SystemLog, Tag, Task, TaskScheduler,
TaskStatus, TaxRate, User, Vendor, Webhook,
}; };
use crate::retry::{self, RetryPolicy}; use crate::retry::{self, RetryPolicy};
use crate::upload::FileUpload; use crate::upload::FileUpload;
@@ -61,6 +62,28 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
Ok(utf8_percent_encode(value, PATH_SEGMENT)) Ok(utf8_percent_encode(value, PATH_SEGMENT))
} }
/// Standard base64 with padding. Only used for the one header that needs it,
/// which is not worth a dependency.
fn base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = chunk
.iter()
.enumerate()
.fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
for i in 0..4 {
if i <= chunk.len() {
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
/// Renders the start of a response body for an error message. /// Renders the start of a response body for an error message.
/// ///
/// Collapsed to one line and truncated, so a stray HTML error page or a /// Collapsed to one line and truncated, so a stray HTML error page or a
@@ -102,9 +125,9 @@ fn excerpt(bytes: &[u8]) -> String {
pub struct InvoiceNinjaClient { pub struct InvoiceNinjaClient {
http: reqwest::Client, http: reqwest::Client,
base_url: String, base_url: String,
/// Marked sensitive, so it renders as `Sensitive` rather than the value /// The header to send it under, and its value. Marked sensitive, so it
/// in the derived `Debug`. /// renders as `Sensitive` rather than the value in the derived `Debug`.
api_password: Option<header::HeaderValue>, api_password: Option<(&'static str, header::HeaderValue)>,
timeout: Duration, timeout: Duration,
retry: RetryPolicy, retry: RetryPolicy,
} }
@@ -185,7 +208,14 @@ impl InvoiceNinjaClient {
/// middleware and reject requests without the account password, even /// middleware and reject requests without the account password, even
/// when the token is valid: reading or writing an individual user, /// when the token is valid: reading or writing an individual user,
/// `/users/bulk`, purging a client or company, and merging clients. /// `/users/bulk`, purging a client or company, and merging clients.
/// Without this, those calls fail rather than returning data. /// Without this, those calls fail rather than returning data. (The
/// server also lets a recent password-authenticated session through
/// without the header, so an omitted password is not always refused.)
///
/// A password that fits a header value is sent as `X-API-PASSWORD`. One
/// that does not (it contains a control character such as a newline) is
/// sent as `X-API-PASSWORD-BASE64`, which the server's middleware decodes.
/// It used to be refused locally with a misleading `InvalidToken`.
/// ///
/// ```no_run /// ```no_run
/// # fn main() -> invoiceninjars::Result<()> { /// # fn main() -> invoiceninjars::Result<()> {
@@ -197,10 +227,17 @@ impl InvoiceNinjaClient {
/// # } /// # }
/// ``` /// ```
pub fn with_api_password(mut self, password: impl Into<String>) -> Result<Self> { pub fn with_api_password(mut self, password: impl Into<String>) -> Result<Self> {
let mut value = header::HeaderValue::from_str(&password.into()) let password = password.into();
.map_err(|e| Error::InvalidToken(e.to_string()))?; let (name, mut value) = match header::HeaderValue::from_str(&password) {
Ok(value) => ("X-API-PASSWORD", value),
Err(_) => (
"X-API-PASSWORD-BASE64",
header::HeaderValue::from_str(&base64(password.as_bytes()))
.expect("base64 output is always a valid header value"),
),
};
value.set_sensitive(true); value.set_sensitive(true);
self.api_password = Some(value); self.api_password = Some((name, value));
Ok(self) Ok(self)
} }
@@ -212,7 +249,7 @@ impl InvoiceNinjaClient {
.request(method, self.url(path)) .request(method, self.url(path))
.timeout(self.timeout); .timeout(self.timeout);
match &self.api_password { match &self.api_password {
Some(password) => builder.header("X-API-PASSWORD", password.clone()), Some((name, password)) => builder.header(*name, password.clone()),
None => builder, None => builder,
} }
} }
@@ -596,6 +633,10 @@ impl InvoiceNinjaClient {
async fn get_discard(&self, path: &str) -> Result<()> { async fn get_discard(&self, path: &str) -> Result<()> {
self.get_raw(path).await.map(|_| ()) self.get_raw(path).await.map(|_| ())
} }
async fn post_discard<B: Serialize>(&self, path: &str, payload: &B) -> Result<()> {
self.post_raw(path, payload).await.map(|_| ())
}
} }
/// Generates typed list/list_all/get/create/update/delete methods for a /// Generates typed list/list_all/get/create/update/delete methods for a
@@ -1419,3 +1460,450 @@ impl InvoiceNinjaClient {
.await .await
} }
} }
/// Per-resource routes that sit beside the CRUD set. Each answers with its
/// own shape (checked against the upstream controllers), so none of them is
/// macro-generated.
///
/// All are `POST` unless noted, so none is retried on a 5xx or a timeout.
/// Several are no-body triggers; they send `{}` rather than an empty body.
impl InvoiceNinjaClient {
// --- webhooks, tasks, designs ---------------------------------------
/// Re-sends a webhook for one entity. `body` takes `entity` (e.g.
/// `invoice`) and `entity_id`. Answers with the webhook itself, not the
/// delivery result.
pub async fn retry_webhook<B: Serialize>(&self, id: &str, body: &B) -> Result<Webhook> {
self.post_one(&format!("webhooks/{}/retry", seg(id)?), body)
.await
}
/// Reorders task-board columns and the tasks in them. `body` takes
/// `status_ids` (task status ids, in order) and `task_ids` (a map from
/// task status id to the ordered task ids in that column).
pub async fn sort_tasks<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("tasks/sort", body).await
}
/// Makes a design the default for an entity. `body` takes `design_id`
/// and `entity`, and optionally `settings_level` (`company`,
/// `group_settings`, or `client`) with the matching `group_settings_id`
/// or `client_id`.
pub async fn set_default_design<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("designs/set/default", body).await
}
// --- client gateway tokens ------------------------------------------
/// Makes a stored payment method the client's default.
pub async fn set_default_client_gateway_token(&self, id: &str) -> Result<ClientGatewayToken> {
self.post_one(
&format!("client_gateway_tokens/{}/setAsDefault", seg(id)?),
&serde_json::json!({}),
)
.await
}
// --- subscriptions --------------------------------------------------
/// The steps of the purchase flow and what each depends on.
///
/// There is no `data` wrapper. The server sends a JSON object keyed by
/// the PHP class behind each step (the controller's `collect()` keeps
/// those keys), though the upstream source reads as if it were a list.
/// Both shapes decode here; the keys are dropped, since each step's `id`
/// says the same thing, and the order of the result is not meaningful.
pub async fn subscription_steps(&self) -> Result<Vec<SubscriptionStep>> {
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum Steps {
List(Vec<SubscriptionStep>),
Keyed(std::collections::BTreeMap<String, SubscriptionStep>),
}
Ok(match self.get_unwrapped("subscriptions/steps").await? {
Steps::List(steps) => steps,
Steps::Keyed(steps) => steps.into_values().collect(),
})
}
/// Validates a proposed `steps` list against the dependency rules.
/// Succeeds on a valid list; an invalid one is a 422
/// ([`Error::Api`]) carrying the reason.
pub async fn check_subscription_steps<B: Serialize>(&self, body: &B) -> Result<()> {
self.post_discard("subscriptions/steps/check", body).await
}
// --- company gateways -----------------------------------------------
/// Asks the gateway to verify its credentials. The outcome is in the
/// returned message; a failed check is still an HTTP 200.
pub async fn test_company_gateway(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("company_gateways/{}/test", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Duplicates a company gateway, returning the copy.
pub async fn clone_company_gateway(&self, id: &str) -> Result<CompanyGateway> {
self.post_one(
&format!("company_gateways/{}/clone", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Starts importing the gateway's customers as clients. The import runs
/// in the background; the message only confirms it was queued.
pub async fn import_company_gateway_customers(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("company_gateways/{}/import_customers", seg(id)?),
&serde_json::json!({}),
)
.await
}
// --- bank integrations and transactions -----------------------------
/// Asks the server to refresh every linked bank account. The response
/// body is a serialised query builder with no usable content, so it is
/// discarded; list the integrations afterwards to see the result.
/// Throttled to 30 calls a minute.
pub async fn refresh_bank_accounts(&self) -> Result<()> {
self.post_discard("bank_integrations/refresh_accounts", &serde_json::json!({}))
.await
}
/// Disconnects a bank account, returning the updated integration.
/// `account_id` is the provider's account id, not the integration's.
pub async fn remove_bank_account(&self, account_id: &str) -> Result<BankIntegration> {
self.post_one(
&format!("bank_integrations/remove_account/{}", seg(account_id)?),
&serde_json::json!({}),
)
.await
}
/// Starts fetching transactions for every auto-sync integration. Despite
/// the `account_id` in the route, the server ignores it. Throttled to one
/// call a minute.
pub async fn fetch_bank_transactions(&self, account_id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("bank_integrations/get_transactions/{}", seg(account_id)?),
&serde_json::json!({}),
)
.await
}
/// Matches bank transactions to invoices, payments, or expenses. `body`
/// takes a `transactions` array; the matched transactions come back.
pub async fn match_bank_transactions<B: Serialize>(
&self,
body: &B,
) -> Result<Vec<BankTransaction>> {
self.post_list("bank_transactions/match", body).await
}
// --- client extras --------------------------------------------------
/// Re-fetches the client's tax data from the tax provider. Throttled to
/// 10 calls a minute.
pub async fn update_client_tax_data(&self, id: &str) -> Result<ClientModel> {
self.post_one(
&format!("clients/{}/updateTaxData", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// The client's effective settings, each tagged with the level it came
/// from. Left as raw JSON: the map's keys are the settings themselves.
pub async fn client_show_settings(&self, id: &str) -> Result<serde_json::Value> {
self.post_unwrapped(
&format!("clients/{}/show_settings", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Looks the client up on the Peppol network. Left as raw JSON: the
/// controller returns whatever the discovery service produced.
pub async fn client_peppol_discovery(&self, id: &str) -> Result<serde_json::Value> {
self.post_unwrapped(
&format!("clients/{}/peppol_discovery", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Validates the client's VAT number. Updates the client's VAT status as
/// a side effect.
pub async fn check_client_vat(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("clients/{}/check_vat", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// The client's VAT validation status. A `GET`, and the only read-only
/// method in this block.
pub async fn client_vat_status(&self, id: &str) -> Result<MessageResponse> {
self.get_unwrapped(&format!("clients/{}/vat_status", seg(id)?))
.await
}
/// Every document attached to the client or to its invoices, quotes,
/// credits, expenses, payments, tasks, recurring invoices, recurring
/// expenses, and projects. `body` takes `filter` (a name substring) and
/// `sort` (`column|asc` or `column|desc`). Returns one page.
pub async fn client_documents<B: Serialize>(
&self,
id: &str,
body: &B,
) -> Result<Vec<Document>> {
self.post_list(&format!("clients/{}/documents", seg(id)?), body)
.await
}
}
/// Destructive actions behind the API's `password_protected` middleware.
///
/// Every method here needs
/// [`with_api_password`](InvoiceNinjaClient::with_api_password); without it
/// the server normally refuses the request. Most are **irreversible**: a purge
/// force-deletes rows rather than archiving them, and a merge removes one of
/// the two records.
///
/// All are `POST`s, so none is retried on a 5xx or a timeout. That matters
/// most here: replaying a merge after a lost response could act on a record
/// that no longer exists.
impl InvoiceNinjaClient {
/// Permanently deletes a client and its documents. Unlike
/// [`delete_client`](Self::delete_client), which soft-deletes, this cannot
/// be undone.
pub async fn purge_client(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("clients/{}/purge", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Merges `mergeable_id` into `id`, returning the client that is kept.
///
/// The route is `clients/{id}/{mergeable_id}/merge` and the controller
/// calls `merge` on the first, so `id` is the survivor. Merging a client
/// into itself, or naming one that does not exist, is a 400.
pub async fn merge_clients(&self, id: &str, mergeable_id: &str) -> Result<ClientModel> {
self.post_one(
&format!("clients/{}/{}/merge", seg(id)?, seg(mergeable_id)?),
&serde_json::json!({}),
)
.await
}
/// Merges `mergeable_id` into `id`, returning the vendor that is kept.
/// Same semantics as [`merge_clients`](Self::merge_clients).
pub async fn merge_vendors(&self, id: &str, mergeable_id: &str) -> Result<Vendor> {
self.post_one(
&format!("vendors/{}/{}/merge", seg(id)?, seg(mergeable_id)?),
&serde_json::json!({}),
)
.await
}
/// Deletes a company. The hosted app refuses to purge its default
/// company with a 400.
pub async fn purge_company(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("companies/purge/{}", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Force-deletes a company's clients, products, projects, tasks, vendors,
/// expenses, purchase orders, bank transactions and activity, and resets
/// its number counters. The company, its settings, and its invoices
/// remain as the server leaves them.
pub async fn purge_company_save_settings(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("companies/purge_save_settings/{}", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Re-sends a user's invitation email.
pub async fn invite_user(&self, id: &str) -> Result<MessageResponse> {
self.post_unwrapped(
&format!("users/{}/invite", seg(id)?),
&serde_json::json!({}),
)
.await
}
/// Permanently deletes a user. Only the account owner may, and not
/// themselves. The server answers 204 with no body.
pub async fn purge_user(&self, id: &str) -> Result<()> {
self.post_discard(&format!("users/{}/purge", seg(id)?), &serde_json::json!({}))
.await
}
/// A blank user as the server would create it, for filling in and
/// passing to [`create_user`](Self::create_user). Nothing is stored.
pub async fn new_user_template(&self) -> Result<User> {
self.get_one("users/create").await
}
}
/// Activity log, email history, and document previews. Each has a response
/// shape of its own (read from the upstream controllers, and for the activity
/// routes confirmed against a 5.13.50 server), so none is macro-generated.
impl InvoiceNinjaClient {
// --- activities -----------------------------------------------------
/// One page of the activity log, newest first.
///
/// The server takes its row limit from a `rows` parameter (default 75)
/// rather than `per_page`, though it paginates the result as well; use
/// [`ListParams::param`] to pass `rows`.
pub async fn list_activities(&self, params: &ListParams) -> Result<Vec<Activity>> {
self.get_list("activities", params).await
}
pub async fn list_all_activities(&self, params: &ListParams) -> Result<Vec<Activity>> {
self.get_all_pages("activities", params).await
}
/// The activity log as the web app shows it: each entry's records are
/// resolved into labelled references, newest first. Non-admins see only
/// their own activity. `rows` defaults to 75 on the server.
pub async fn activity_feed(&self, rows: Option<u32>) -> Result<Vec<ActivityFeedEntry>> {
let mut params = ListParams::new().param("reactv2", "1");
if let Some(rows) = rows {
params = params.param("rows", rows.to_string());
}
self.get_list("activities", &params).await
}
/// The feed for one record. `entity` is the **singular** name (`invoice`,
/// `quote`, `credit`, `purchase_order`, `payment`, `client`, `vendor`,
/// `expense`, `task`, `project`, `subscription`, `recurring_invoice`).
/// Contrast [`add_activity_note`](Self::add_activity_note), which wants
/// the plural.
pub async fn entity_activity(
&self,
entity: &str,
entity_id: &str,
rows: Option<u32>,
) -> Result<Vec<ActivityFeedEntry>> {
let mut body = serde_json::json!({ "entity": entity, "entity_id": entity_id });
// Left out rather than sent as null: the server tests for the key's
// presence, so a null `rows` would count as given and remove the cap.
if let Some(rows) = rows {
body["rows"] = rows.into();
}
self.post_list("activities/entity", &body).await
}
/// Adds a note to a record's history. `entity` is the **plural** table
/// name (`invoices`, `quotes`, `credits`, `recurring_invoices`,
/// `clients`, `vendors`, `payments`, `projects`, `tasks`, `expenses`,
/// `recurring_expenses`, `bank_transactions`, `purchase_orders`), unlike
/// [`entity_activity`](Self::entity_activity).
///
/// Not idempotent: each call adds another note, so it is not retried on a
/// 5xx.
pub async fn add_activity_note(
&self,
entity: &str,
entity_id: &str,
notes: &str,
) -> Result<Activity> {
self.post_one(
"activities/notes",
&serde_json::json!({ "entity": entity, "entity_id": entity_id, "notes": notes }),
)
.await
}
/// The PDF of an invoice, quote or credit as it was when the activity was
/// recorded. A 404 means that activity kept no backup.
pub async fn download_activity_entity(&self, activity_id: &str) -> Result<Vec<u8>> {
self.get_raw(&format!("activities/download_entity/{}", seg(activity_id)?))
.await
}
// --- email history --------------------------------------------------
/// The client's email delivery history, newest first: one object per sent
/// message that has delivery events. Left as raw JSON, since the objects
/// mirror whatever the mail provider reported and no instance available
/// here had sent mail to take a shape from.
pub async fn client_email_history(&self, client_id: &str) -> Result<Vec<serde_json::Value>> {
self.post_unwrapped(
&format!("emails/clientHistory/{}", seg(client_id)?),
&serde_json::json!({}),
)
.await
}
/// Email delivery history for one record. `entity` is `invoice`, `quote`,
/// `credit`, `recurring_invoice`, or `purchase_order`. Raw JSON, as for
/// [`client_email_history`](Self::client_email_history).
pub async fn entity_email_history(
&self,
entity: &str,
entity_id: &str,
) -> Result<Vec<serde_json::Value>> {
self.post_unwrapped(
"emails/entityHistory",
&serde_json::json!({ "entity": entity, "entity_id": entity_id }),
)
.await
}
// --- previews -------------------------------------------------------
//
// These render a document without saving it and answer with a PDF, so
// they return the bytes. `body` is the entity to render plus, for the
// design routes, the `design` object.
/// Renders a preview of an existing record or of an unsaved design.
/// Takes `entity`, `entity_id` (`-1` for a mock record) and `design`.
pub async fn preview<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
self.post_raw("preview", body).await
}
/// [`preview`](Self::preview) as the HTML the PDF is made from.
pub async fn preview_html<B: Serialize>(&self, body: &B) -> Result<String> {
let bytes = self.post_raw("preview?html=true", body).await?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
/// Renders an unsaved invoice, quote, credit or recurring invoice as a
/// PDF: the body is the entity's own fields, plus `entity` and
/// `entity_id`.
pub async fn live_preview<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
self.post_raw("live_preview", body).await
}
/// Renders a design against mock data, for editing it.
pub async fn live_design<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
self.post_raw("live_design", body).await
}
/// [`preview`](Self::preview) for a purchase order.
pub async fn preview_purchase_order<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
self.post_raw("preview/purchase_order", body).await
}
/// [`live_preview`](Self::live_preview) for a purchase order.
pub async fn live_preview_purchase_order<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
self.post_raw("live_preview/purchase_order", body).await
}
}
+746
View File
@@ -1475,3 +1475,749 @@ async fn transport_errors_say_why_the_request_failed() {
"the cause should appear in the message, got: {rendered}" "the cause should appear in the message, got: {rendered}"
); );
} }
// --- per-resource extras ------------------------------------------------
/// Mounts one route answering 200 with `body`, and returns the server.
async fn route_server(verb: &str, route: &str, body: serde_json::Value) -> MockServer {
let server = MockServer::start().await;
Mock::given(method(verb))
.and(path(route))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.expect(1)
.mount(&server)
.await;
server
}
/// Generates one test per extra. Each mounts the route the upstream router
/// registers, calls the method, and checks it decoded the shape the
/// controller returns. `route_server` expects exactly one request, so a call
/// that hit a different path or verb fails when the server drops.
macro_rules! extra_test {
($name:ident, $verb:literal, $route:literal, $response:expr,
|$c:ident| $call:expr, |$got:ident| $check:expr) => {
#[tokio::test]
async fn $name() {
let server = route_server($verb, $route, $response).await;
let $c = client(&server);
let $got = $call.await.expect("the extra should succeed");
$check;
}
};
}
fn message() -> serde_json::Value {
json!({ "message": "ok" })
}
fn entity() -> serde_json::Value {
json!({ "data": { "id": "abc" } })
}
extra_test!(
retry_webhook_posts_to_retry_and_returns_the_webhook,
"POST",
"/webhooks/abc/retry",
entity(),
|c| c.retry_webhook("abc", &json!({ "entity": "invoice", "entity_id": "i1" })),
|hook| assert_eq!(hook.id.as_deref(), Some("abc"))
);
extra_test!(
sort_tasks_returns_the_message,
"POST",
"/tasks/sort",
message(),
|c| c.sort_tasks(&json!({ "status_ids": ["s1"], "task_ids": { "s1": ["t1"] } })),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
set_default_design_returns_the_message,
"POST",
"/designs/set/default",
message(),
|c| c.set_default_design(&json!({ "design_id": "d1", "entity": "invoice" })),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
set_default_client_gateway_token_returns_the_token,
"POST",
"/client_gateway_tokens/abc/setAsDefault",
json!({ "data": { "id": "abc", "is_default": true } }),
|c| c.set_default_client_gateway_token("abc"),
|token| assert!(token.is_default)
);
extra_test!(
test_company_gateway_returns_the_message,
"POST",
"/company_gateways/abc/test",
message(),
|c| c.test_company_gateway("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
clone_company_gateway_returns_the_copy,
"POST",
"/company_gateways/abc/clone",
entity(),
|c| c.clone_company_gateway("abc"),
|copy| assert_eq!(copy.id.as_deref(), Some("abc"))
);
extra_test!(
import_company_gateway_customers_returns_the_message,
"POST",
"/company_gateways/abc/import_customers",
message(),
|c| c.import_company_gateway_customers("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
remove_bank_account_returns_the_integration,
"POST",
"/bank_integrations/remove_account/acc1",
entity(),
|c| c.remove_bank_account("acc1"),
|got| assert_eq!(got.id.as_deref(), Some("abc"))
);
extra_test!(
fetch_bank_transactions_returns_the_message,
"POST",
"/bank_integrations/get_transactions/acc1",
message(),
|c| c.fetch_bank_transactions("acc1"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
match_bank_transactions_returns_the_matched_rows,
"POST",
"/bank_transactions/match",
json!({ "data": [{ "id": "bt1" }] }),
|c| c.match_bank_transactions(&json!({ "transactions": [] })),
|got| assert_eq!(got.len(), 1)
);
extra_test!(
update_client_tax_data_returns_the_client,
"POST",
"/clients/abc/updateTaxData",
entity(),
|c| c.update_client_tax_data("abc"),
|got| assert_eq!(got.id.as_deref(), Some("abc"))
);
extra_test!(
client_show_settings_returns_raw_json,
"POST",
"/clients/abc/show_settings",
json!({ "currency_id": "1" }),
|c| c.client_show_settings("abc"),
|got| assert_eq!(got["currency_id"], "1")
);
extra_test!(
client_peppol_discovery_returns_raw_json,
"POST",
"/clients/abc/peppol_discovery",
json!({ "found": false }),
|c| c.client_peppol_discovery("abc"),
|got| assert_eq!(got["found"], false)
);
extra_test!(
check_client_vat_returns_the_message,
"POST",
"/clients/abc/check_vat",
message(),
|c| c.check_client_vat("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
client_vat_status_is_a_get,
"GET",
"/clients/abc/vat_status",
message(),
|c| c.client_vat_status("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
client_documents_returns_one_page_of_documents,
"POST",
"/clients/abc/documents",
json!({ "data": [{ "id": "d1" }] }),
|c| c.client_documents("abc", &json!({ "filter": "x" })),
|got| assert_eq!(got.len(), 1)
);
/// The body a caller passes must reach the server, not just the route.
#[tokio::test]
async fn retry_webhook_sends_the_callers_body() {
let server = route_server("POST", "/webhooks/abc/retry", entity()).await;
client(&server)
.retry_webhook("abc", &json!({ "entity": "invoice", "entity_id": "i1" }))
.await
.unwrap();
let sent: serde_json::Value = server.received_requests().await.unwrap()[0]
.body_json()
.unwrap();
assert_eq!(sent, json!({ "entity": "invoice", "entity_id": "i1" }));
}
/// The no-body triggers send `{}` with a JSON content type rather than an
/// empty body, which is what Laravel's request parsing is happy with.
#[tokio::test]
async fn no_body_triggers_send_an_empty_json_object() {
let server = route_server("POST", "/company_gateways/abc/test", message()).await;
client(&server).test_company_gateway("abc").await.unwrap();
let request = &server.received_requests().await.unwrap()[0];
assert_eq!(request.body_json::<serde_json::Value>().unwrap(), json!({}));
assert_eq!(
request.headers.get("content-type").unwrap(),
"application/json"
);
}
#[tokio::test]
async fn subscription_steps_decode_a_list_or_a_keyed_object_and_check_discards_the_body() {
let s = route_server(
"GET",
"/subscriptions/steps",
json!([
{ "id": "auth.login-or-register", "dependencies": [] },
{ "id": "payment_method", "dependencies": ["auth.login-or-register"] }
]),
)
.await;
let steps = client(&s).subscription_steps().await.unwrap();
assert_eq!(steps.len(), 2);
assert_eq!(steps[1].dependencies, ["auth.login-or-register"]);
// What a real 5.13.50 server sends: an object keyed by PHP class name.
let s = route_server(
"GET",
"/subscriptions/steps",
json!({
"App\\Livewire\\Login": { "id": "auth.login", "dependencies": [] },
"App\\Livewire\\Pay": { "id": "payment_method", "dependencies": ["auth.login"] }
}),
)
.await;
let mut steps = client(&s).subscription_steps().await.unwrap();
steps.sort_by(|a, b| a.id.cmp(&b.id));
assert_eq!(steps[0].id, "auth.login");
assert_eq!(steps[1].dependencies, ["auth.login"]);
let s = route_server("POST", "/subscriptions/steps/check", json!([])).await;
client(&s)
.check_subscription_steps(&json!({ "steps": "a,b" }))
.await
.unwrap();
}
#[tokio::test]
async fn refresh_bank_accounts_ignores_the_meaningless_body() {
let s = route_server("POST", "/bank_integrations/refresh_accounts", json!({})).await;
client(&s).refresh_bank_accounts().await.unwrap();
}
#[tokio::test]
async fn extras_validate_ids_and_surface_errors() {
let server = MockServer::start().await;
let c = client(&server);
// A `/` in an id stays inside one segment instead of retargeting the call.
let _ = c.check_client_vat("a/b").await;
let seen = server.received_requests().await.unwrap();
assert_eq!(seen[0].url.path(), "/clients/a%2Fb/check_vat");
assert!(matches!(
c.check_client_vat("..").await,
Err(Error::InvalidPathSegment(_))
));
assert!(matches!(
c.remove_bank_account("").await,
Err(Error::InvalidPathSegment(_))
));
assert!(matches!(
c.test_company_gateway("..").await,
Err(Error::InvalidPathSegment(_))
));
Mock::given(method("POST"))
.and(path("/subscriptions/steps/check"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"message": "invalid",
"errors": { "steps": ["bad order"] }
})))
.mount(&server)
.await;
let err = c
.check_subscription_steps(&json!({ "steps": "b,a" }))
.await
.expect_err("an invalid step list is a 422");
assert!(
matches!(err, Error::Api { status, .. } if status == 422),
"{err:?}"
);
}
/// These are all `POST`s, and some start work on the server, so a 5xx must
/// not be replayed.
#[tokio::test]
async fn extras_are_not_replayed_on_5xx() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/company_gateways/abc/import_customers"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
client(&server)
.with_retry_policy(quick_retry())
.import_company_gateway_customers("abc")
.await
.expect_err("a failed trigger must not be retried");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
// --- password-protected actions -----------------------------------------
extra_test!(
purge_client_returns_the_message,
"POST",
"/clients/abc/purge",
message(),
|c| c.purge_client("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
merge_clients_names_the_survivor_first_and_returns_it,
"POST",
"/clients/keep/drop/merge",
json!({ "data": { "id": "keep" } }),
|c| c.merge_clients("keep", "drop"),
|got| assert_eq!(got.id.as_deref(), Some("keep"))
);
extra_test!(
merge_vendors_names_the_survivor_first_and_returns_it,
"POST",
"/vendors/keep/drop/merge",
json!({ "data": { "id": "keep" } }),
|c| c.merge_vendors("keep", "drop"),
|got| assert_eq!(got.id.as_deref(), Some("keep"))
);
extra_test!(
purge_company_returns_the_message,
"POST",
"/companies/purge/abc",
message(),
|c| c.purge_company("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
purge_company_save_settings_returns_the_message,
"POST",
"/companies/purge_save_settings/abc",
message(),
|c| c.purge_company_save_settings("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
invite_user_returns_the_message,
"POST",
"/users/abc/invite",
message(),
|c| c.invite_user("abc"),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
new_user_template_is_a_get_returning_a_blank_user,
"GET",
"/users/create",
entity(),
|c| c.new_user_template(),
|got| assert_eq!(got.id.as_deref(), Some("abc"))
);
/// User purge answers 204 with no body, which must not be treated as a decode
/// failure.
#[tokio::test]
async fn purge_user_accepts_an_empty_204() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/users/abc/purge"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
client(&server).purge_user("abc").await.unwrap();
}
/// Both ids in a merge go through the same path protection, so neither can
/// retarget the request.
#[tokio::test]
async fn merge_validates_both_ids() {
let server = MockServer::start().await;
let c = client(&server);
assert!(matches!(
c.merge_clients("..", "x").await,
Err(Error::InvalidPathSegment(_))
));
assert!(matches!(
c.merge_clients("x", "").await,
Err(Error::InvalidPathSegment(_))
));
assert!(matches!(
c.merge_vendors("x", "..").await,
Err(Error::InvalidPathSegment(_))
));
let _ = c.merge_clients("a/b", "c?d").await;
let seen = server.received_requests().await.unwrap();
assert_eq!(seen.len(), 1, "only the escaped call may reach the server");
assert_eq!(seen[0].url.path(), "/clients/a%2Fb/c%3Fd/merge");
}
/// A destructive POST that may have been applied must never be replayed.
#[tokio::test]
async fn destructive_actions_are_not_replayed_on_5xx() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let c = client(&server).with_retry_policy(quick_retry());
c.purge_client("abc").await.expect_err("500");
c.merge_clients("a", "b").await.expect_err("500");
c.purge_company("abc").await.expect_err("500");
c.purge_user("abc").await.expect_err("500");
assert_eq!(server.received_requests().await.unwrap().len(), 4);
}
/// The password reaches the server on these routes, and without one the
/// header is simply absent.
#[tokio::test]
async fn the_api_password_is_sent_on_protected_actions() {
let server = route_server("POST", "/clients/abc/purge", message()).await;
client(&server)
.with_api_password("hunter2")
.unwrap()
.purge_client("abc")
.await
.unwrap();
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(sent.headers.get("X-API-PASSWORD").unwrap(), "hunter2");
assert!(sent.headers.get("X-API-PASSWORD-BASE64").is_none());
}
/// A password with a control character cannot be a header value. It used to be
/// rejected with a misleading `InvalidToken`; it now goes out base64 encoded,
/// under the header the server's middleware decodes. The cases cover each
/// padding form (`==`, `=`, none).
#[tokio::test]
async fn a_password_with_a_control_character_goes_out_base64_encoded() {
for (password, expected) in [("\n", "Cg=="), ("a\n", "YQo="), ("a\nb", "YQpi")] {
let server = route_server("POST", "/clients/abc/purge", message()).await;
client(&server)
.with_api_password(password)
.expect("any password is accepted")
.purge_client("abc")
.await
.unwrap();
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(
sent.headers.get("X-API-PASSWORD-BASE64").unwrap(),
expected,
"password {password:?}"
);
assert!(
sent.headers.get("X-API-PASSWORD").is_none(),
"the plain header must not also be sent"
);
}
}
/// Non-ASCII text is a valid header value, so those passwords keep going out
/// as `X-API-PASSWORD` exactly as before.
#[tokio::test]
async fn a_non_ascii_password_still_goes_out_as_the_plain_header() {
let server = route_server("POST", "/clients/abc/purge", message()).await;
client(&server)
.with_api_password("pässwörd")
.unwrap()
.purge_client("abc")
.await
.unwrap();
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(
sent.headers.get("X-API-PASSWORD").unwrap().as_bytes(),
"pässwörd".as_bytes()
);
assert!(sent.headers.get("X-API-PASSWORD-BASE64").is_none());
}
/// The base64 fallback is as secret as the password itself, so it must be
/// redacted from `Debug` too.
#[tokio::test]
async fn debug_output_redacts_a_base64_password_too() {
let c = InvoiceNinjaClient::new("http://localhost/api/v1", "tok")
.unwrap()
.with_api_password("S3CRETpw\nX")
.unwrap();
let shown = format!("{c:?}");
assert!(!shown.contains("S3CRET"), "{shown}");
assert!(!shown.contains("UzNDUkVUcHcK"), "{shown}");
assert!(shown.contains("Sensitive"), "{shown}");
}
// --- activities, email history, previews --------------------------------
extra_test!(
list_activities_decodes_the_transformer_shape,
"GET",
"/activities",
json!({ "data": [{
"id": "a1", "activity_type_id": "10", "client_id": "c1", "invoice_id": "",
"is_system": false, "notes": "", "ip": "127.0.0.1",
"created_at": 1792869488, "updated_at": 1792869488
}] }),
|c| c.list_activities(&ListParams::new()),
|got| {
assert_eq!(got.len(), 1);
assert_eq!(got[0].activity_type_id, "10");
assert_eq!(got[0].created_at, Some(1792869488));
}
);
/// The feed is a different shape: numeric type, labelled references.
#[tokio::test]
async fn activity_feed_asks_for_reactv2_and_decodes_labelled_references() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/activities"))
.and(query_param("reactv2", "1"))
.and(query_param("rows", "5"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": [{
"id": "7", "hashed_id": "h7", "activity_type_id": 10, "notes": "", "ip": "",
"created_at": 1792869488,
"user": { "label": "Ann", "hashed_id": "u1" },
"invoice": { "label": "0001", "hashed_id": "i1" },
"payment_amount": "5.00"
}] })))
.expect(1)
.mount(&server)
.await;
let feed = client(&server).activity_feed(Some(5)).await.unwrap();
assert_eq!(feed[0].activity_type_id, 10);
assert_eq!(feed[0].invoice.as_ref().unwrap().label, "0001");
assert!(feed[0].client.is_none());
assert_eq!(feed[0].extra["payment_amount"], "5.00");
}
#[tokio::test]
async fn activity_feed_without_rows_sends_no_rows_parameter() {
let server = route_server("GET", "/activities", json!({ "data": [] })).await;
client(&server).activity_feed(None).await.unwrap();
let url = &server.received_requests().await.unwrap()[0].url;
assert!(
!url.query_pairs().any(|(k, _)| k == "rows"),
"the server picks its own default, got {url}"
);
}
/// The server tests for the key, not its value, so `rows: null` would count
/// as given and `take(null)` would drop the limit.
#[tokio::test]
async fn entity_activity_omits_rows_unless_given() {
for (rows, expected) in [
(None, json!({ "entity": "client", "entity_id": "c1" })),
(
Some(3),
json!({ "entity": "client", "entity_id": "c1", "rows": 3 }),
),
] {
let server = route_server("POST", "/activities/entity", json!({ "data": [] })).await;
client(&server)
.entity_activity("client", "c1", rows)
.await
.unwrap();
let sent: serde_json::Value = server.received_requests().await.unwrap()[0]
.body_json()
.unwrap();
assert_eq!(sent, expected);
}
}
extra_test!(
add_activity_note_returns_the_activity,
"POST",
"/activities/notes",
json!({ "data": { "id": "a9", "notes": "called them" } }),
|c| c.add_activity_note("invoices", "i1", "called them"),
|got| assert_eq!(got.notes, "called them")
);
#[tokio::test]
async fn download_activity_entity_returns_the_pdf_bytes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/activities/download_entity/a1"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4".to_vec()))
.expect(1)
.mount(&server)
.await;
assert_eq!(
client(&server)
.download_activity_entity("a1")
.await
.unwrap(),
b"%PDF-1.4"
);
}
#[tokio::test]
async fn a_missing_activity_backup_is_an_api_error_not_a_decode_failure() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(404)
.set_body_json(json!({ "message": "No backup exists", "errors": {} })),
)
.mount(&server)
.await;
let err = client(&server)
.download_activity_entity("a1")
.await
.expect_err("404");
assert!(
matches!(err, Error::Api { status, .. } if status == 404),
"{err:?}"
);
}
extra_test!(
client_email_history_decodes_a_bare_array,
"POST",
"/emails/clientHistory/c1",
json!([{ "entity": "invoice", "events": [{ "event": "delivered" }] }]),
|c| c.client_email_history("c1"),
|got| assert_eq!(got[0]["events"][0]["event"], "delivered")
);
extra_test!(
entity_email_history_sends_entity_and_id,
"POST",
"/emails/entityHistory",
json!([]),
|c| c.entity_email_history("invoice", "i1"),
|got| assert!(got.is_empty())
);
/// Every preview returns raw bytes (a PDF), never decodes JSON, and is
/// routed to its own path.
#[tokio::test]
async fn previews_return_bytes_from_their_own_routes() {
let routes = [
"/preview",
"/live_preview",
"/live_design",
"/preview/purchase_order",
"/live_preview/purchase_order",
];
for route in routes {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(route))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4".to_vec()))
.expect(1)
.mount(&server)
.await;
let c = client(&server);
let body = json!({ "entity": "invoice", "entity_id": "-1" });
let got = match route {
"/preview" => c.preview(&body).await,
"/live_preview" => c.live_preview(&body).await,
"/live_design" => c.live_design(&body).await,
"/preview/purchase_order" => c.preview_purchase_order(&body).await,
_ => c.live_preview_purchase_order(&body).await,
};
assert_eq!(got.unwrap(), b"%PDF-1.4", "{route}");
}
}
#[tokio::test]
async fn preview_html_asks_for_html_and_returns_text() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/preview"))
.and(query_param("html", "true"))
.respond_with(ResponseTemplate::new(200).set_body_string("<html>x</html>"))
.expect(1)
.mount(&server)
.await;
let html = client(&server)
.preview_html(&json!({ "entity": "invoice", "entity_id": "-1" }))
.await
.unwrap();
assert_eq!(html, "<html>x</html>");
}
/// Previews can be rejected with a JSON validation error rather than a PDF.
#[tokio::test]
async fn a_rejected_preview_keeps_the_validation_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
"message": "invalid data access",
"errors": { "design.design.body": ["bad twig"] }
})))
.mount(&server)
.await;
let err = client(&server).preview(&json!({})).await.expect_err("422");
assert!(
matches!(&err, Error::Api { errors, .. } if errors.contains_key("design.design.body")),
"{err:?}"
);
}
/// Adding a note appends every time, so a lost response must not be replayed.
#[tokio::test]
async fn notes_are_not_replayed_on_5xx() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
client(&server)
.with_retry_policy(quick_retry())
.add_activity_note("invoices", "i1", "x")
.await
.expect_err("500");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}