diff --git a/src/client.rs b/src/client.rs index 188ee26..aea334a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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 { 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, + /// 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) -> Result { - 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(&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(&self, id: &str, body: &B) -> Result { + 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(&self, body: &B) -> Result { + 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(&self, body: &B) -> Result { + 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 { + 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> { + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum Steps { + List(Vec), + Keyed(std::collections::BTreeMap), + } + + 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(&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 { + 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 { + 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 { + 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 { + 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 { + 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( + &self, + body: &B, + ) -> Result> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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( + &self, + id: &str, + body: &B, + ) -> Result> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + self.get_list("activities", params).await + } + + pub async fn list_all_activities(&self, params: &ListParams) -> Result> { + 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) -> Result> { + 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, + ) -> Result> { + 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 { + 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> { + 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> { + 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> { + 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(&self, body: &B) -> Result> { + self.post_raw("preview", body).await + } + + /// [`preview`](Self::preview) as the HTML the PDF is made from. + pub async fn preview_html(&self, body: &B) -> Result { + 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(&self, body: &B) -> Result> { + self.post_raw("live_preview", body).await + } + + /// Renders a design against mock data, for editing it. + pub async fn live_design(&self, body: &B) -> Result> { + self.post_raw("live_design", body).await + } + + /// [`preview`](Self::preview) for a purchase order. + pub async fn preview_purchase_order(&self, body: &B) -> Result> { + self.post_raw("preview/purchase_order", body).await + } + + /// [`live_preview`](Self::live_preview) for a purchase order. + pub async fn live_preview_purchase_order(&self, body: &B) -> Result> { + self.post_raw("live_preview/purchase_order", body).await + } +} diff --git a/tests/client_requests.rs b/tests/client_requests.rs index 26f7c77..00fe225 100644 --- a/tests/client_requests.rs +++ b/tests/client_requests.rs @@ -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::().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("x")) + .expect(1) + .mount(&server) + .await; + let html = client(&server) + .preview_html(&json!({ "entity": "invoice", "entity_id": "-1" })) + .await + .unwrap(); + assert_eq!(html, "x"); +} + +/// 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); +}