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:
+502
-14
@@ -6,12 +6,13 @@ use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
use crate::error::{ApiErrorBody, Error, Result};
|
||||
use crate::models::{
|
||||
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client as ClientModel,
|
||||
ClientGatewayToken, Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense,
|
||||
ExpenseCategory, GroupSetting, HealthCheck, Invoice, ItemResponse, ListParams, ListResponse,
|
||||
Location, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
|
||||
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SystemLog, Tag, Task,
|
||||
TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
|
||||
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
|
||||
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
||||
Credit, Design, Document, Expense, ExpenseCategory, GroupSetting, HealthCheck, Invoice,
|
||||
ItemResponse, ListParams, ListResponse, Location, MessageResponse, Payment, PaymentTerm,
|
||||
Product, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
|
||||
SearchResults, Statics, Subscription, SubscriptionStep, SystemLog, Tag, Task, TaskScheduler,
|
||||
TaskStatus, TaxRate, User, Vendor, Webhook,
|
||||
};
|
||||
use crate::retry::{self, RetryPolicy};
|
||||
use crate::upload::FileUpload;
|
||||
@@ -61,6 +62,28 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
|
||||
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.
|
||||
///
|
||||
/// 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 {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
/// Marked sensitive, so it renders as `Sensitive` rather than the value
|
||||
/// in the derived `Debug`.
|
||||
api_password: Option<header::HeaderValue>,
|
||||
/// The header to send it under, and its value. Marked sensitive, so it
|
||||
/// renders as `Sensitive` rather than the value in the derived `Debug`.
|
||||
api_password: Option<(&'static str, header::HeaderValue)>,
|
||||
timeout: Duration,
|
||||
retry: RetryPolicy,
|
||||
}
|
||||
@@ -185,7 +208,14 @@ impl InvoiceNinjaClient {
|
||||
/// middleware and reject requests without the account password, even
|
||||
/// when the token is valid: reading or writing an individual user,
|
||||
/// `/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
|
||||
/// # fn main() -> invoiceninjars::Result<()> {
|
||||
@@ -197,10 +227,17 @@ impl InvoiceNinjaClient {
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn with_api_password(mut self, password: impl Into<String>) -> Result<Self> {
|
||||
let mut value = header::HeaderValue::from_str(&password.into())
|
||||
.map_err(|e| Error::InvalidToken(e.to_string()))?;
|
||||
let password = password.into();
|
||||
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);
|
||||
self.api_password = Some(value);
|
||||
self.api_password = Some((name, value));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
@@ -212,7 +249,7 @@ impl InvoiceNinjaClient {
|
||||
.request(method, self.url(path))
|
||||
.timeout(self.timeout);
|
||||
match &self.api_password {
|
||||
Some(password) => builder.header("X-API-PASSWORD", password.clone()),
|
||||
Some((name, password)) => builder.header(*name, password.clone()),
|
||||
None => builder,
|
||||
}
|
||||
}
|
||||
@@ -596,6 +633,10 @@ impl InvoiceNinjaClient {
|
||||
async fn get_discard(&self, path: &str) -> Result<()> {
|
||||
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
|
||||
@@ -1419,3 +1460,450 @@ impl InvoiceNinjaClient {
|
||||
.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", ¶ms).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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1475,3 +1475,749 @@ async fn transport_errors_say_why_the_request_failed() {
|
||||
"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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user