Wrap all 34 resource groups, then harden the request plumbing
Expands coverage to every `Route::resource` group in the v5 API plus `users` (265 methods), and fixes several defects in the shared request path that the new breadth made more exposed. Coverage: - 25 new models, each keeping the `extra` flatten map and the `null_to_default` convention so unmodeled fields round-trip. - `crud_methods!` split out of `resource_methods!` for the three groups with no `/bulk` route, where a generated `bulk_*` would only 405. - COVERAGE.md added as the detailed inventory, verified against `routes/api.php` on v5-stable. Fixes, each confirmed against upstream source or an observed request: - `list_all_*` silently truncated. `BaseController` caps page size at `min(abs(per_page), 5000)`, so the "short page means last page" rule stopped early and returned a partial list as if complete. Termination now prefers `meta.pagination.total_pages` (decoded but previously discarded), and `per_page` is clamped so the fallback stays sound. - `per_page(0)` paged forever: the check `got < 0` has no solution. It now falls back to the default size, with a MAX_PAGES backstop that raises `Error::PaginationLimit` rather than quietly truncating. - Ids were interpolated into paths raw, so a `/` or `?` in an id retargeted the request. All sites now use `seg(id)?`. Escaping alone is insufficient: the WHATWG rules `url` implements decode `%2E` to `.` before resolving dot segments, so an escaped `..` still collapses the path. Empty and dot-only ids are rejected. - No request timeout. reqwest applies none, so a stalled connection hung the caller indefinitely. Adds a 60s default and `with_timeout`. - Base URL is validated at construction; a missing scheme used to 404 on every call instead of failing once. Adds tests/client_requests.rs: 26 tests over the request plumbing against a wiremock loopback server, including regressions for the truncation and non-termination bugs. No network or credentials needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
//! Request-plumbing tests: pagination, path escaping, headers, timeouts, and
|
||||
//! error mapping.
|
||||
//!
|
||||
//! These run against a `wiremock` server bound to loopback, so they need no
|
||||
//! network access and no credentials. `tests/model_roundtrip.rs` covers the
|
||||
//! serde layer; this file covers everything between a method call and the
|
||||
//! bytes on the wire.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use invoiceninjars::{models::ListParams, Error, InvoiceNinjaClient};
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Builds a client pointed at the mock server.
|
||||
fn client(server: &MockServer) -> InvoiceNinjaClient {
|
||||
InvoiceNinjaClient::new(server.uri(), "test-token").expect("mock uri is a valid base url")
|
||||
}
|
||||
|
||||
/// A `data`-wrapped list page carrying pagination metadata.
|
||||
fn page(rows: usize, current_page: u64, total_pages: u64) -> serde_json::Value {
|
||||
let data: Vec<_> = (0..rows)
|
||||
.map(|i| json!({ "id": format!("id{i}") }))
|
||||
.collect();
|
||||
json!({
|
||||
"data": data,
|
||||
"meta": {
|
||||
"pagination": {
|
||||
"total": rows as u64 * total_pages,
|
||||
"count": rows as u64,
|
||||
"per_page": rows as u64,
|
||||
"current_page": current_page,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- auth and headers ---------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_and_accept_headers_are_sent() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.and(header("X-Api-Token", "test-token"))
|
||||
.and(header("Accept", "application/json"))
|
||||
.and(header("X-Requested-With", "XMLHttpRequest"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
client(&server)
|
||||
.list_clients(&ListParams::new())
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_password_header_is_attached_when_configured() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/users/u1"))
|
||||
.and(header("X-API-PASSWORD", "hunter2"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "u1" } })))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = client(&server)
|
||||
.with_api_password("hunter2")
|
||||
.expect("password is a valid header value");
|
||||
let user = client.get_user("u1").await.expect("request should succeed");
|
||||
assert_eq!(user.id.as_deref(), Some("u1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_password_header_is_absent_by_default() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
client(&server)
|
||||
.list_clients(&ListParams::new())
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
let sent = &server.received_requests().await.unwrap()[0];
|
||||
assert!(
|
||||
sent.headers.get("X-API-PASSWORD").is_none(),
|
||||
"password header must not be sent unless configured"
|
||||
);
|
||||
}
|
||||
|
||||
// --- path escaping ------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn ids_are_escaped_into_a_single_path_segment() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "x" } })))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// A `/` in the id must not open a new path segment: the whole value
|
||||
// stays under /clients rather than reaching /companies.
|
||||
client(&server)
|
||||
.get_client("../companies/1")
|
||||
.await
|
||||
.expect("escaped id should still reach the clients endpoint");
|
||||
|
||||
let sent = &server.received_requests().await.unwrap()[0];
|
||||
assert_eq!(sent.url.path(), "/clients/%2E%2E%2Fcompanies%2F1");
|
||||
}
|
||||
|
||||
/// Escaping by itself does not make a dot-only id safe: the WHATWG rules the
|
||||
/// `url` crate follows decode `%2E` back to `.` before resolving dot
|
||||
/// segments, so `clients/%2E%2E` still collapses to `/`. Such ids have to be
|
||||
/// refused before a request is built.
|
||||
#[tokio::test]
|
||||
async fn dot_only_ids_are_rejected_before_any_request_is_sent() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("DELETE"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
for id in ["..", ".", "..."] {
|
||||
let err = client(&server)
|
||||
.delete_client(id)
|
||||
.await
|
||||
.expect_err("a dot-only id must not be sent");
|
||||
assert!(
|
||||
matches!(err, Error::InvalidPathSegment(ref v) if v == id),
|
||||
"expected InvalidPathSegment for {id:?}, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
server.received_requests().await.unwrap().is_empty(),
|
||||
"nothing should have reached the server"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_ids_are_rejected_rather_than_hitting_the_collection() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("DELETE"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// `delete_client("")` would otherwise address DELETE /clients.
|
||||
let err = client(&server)
|
||||
.delete_client("")
|
||||
.await
|
||||
.expect_err("an empty id must not be sent");
|
||||
assert!(matches!(err, Error::InvalidPathSegment(_)), "got {err:?}");
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_injection_via_id_is_escaped() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/invoices/abc%3Fper_page%3D1/download"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4".to_vec()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pdf = client(&server)
|
||||
.download_invoice("abc?per_page=1")
|
||||
.await
|
||||
.expect("escaped id should not inject a query string");
|
||||
assert_eq!(pdf, b"%PDF-1.4");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_hashids_are_left_readable() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients/Wpmbk5ezJn"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "Wpmbk5ezJn" } })),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let got = client(&server)
|
||||
.get_client("Wpmbk5ezJn")
|
||||
.await
|
||||
.expect("a normal id should pass through unescaped");
|
||||
assert_eq!(got.id.as_deref(), Some("Wpmbk5ezJn"));
|
||||
}
|
||||
|
||||
// --- pagination ---------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_follows_total_pages_from_metadata() {
|
||||
let server = MockServer::start().await;
|
||||
for p in 1..=3u64 {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.and(query_param("page", p.to_string()))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(page(50, p, 3)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
|
||||
let all = client(&server)
|
||||
.list_all_clients(&ListParams::new())
|
||||
.await
|
||||
.expect("should walk every page");
|
||||
assert_eq!(all.len(), 150, "expected all three pages");
|
||||
}
|
||||
|
||||
/// The regression this rewrite exists for. The server caps `per_page` at
|
||||
/// 5000, so asking for more returns a page shorter than requested while more
|
||||
/// records remain. The old short-page-means-last heuristic stopped there and
|
||||
/// silently dropped the rest.
|
||||
#[tokio::test]
|
||||
async fn capped_page_size_does_not_truncate_the_walk() {
|
||||
let server = MockServer::start().await;
|
||||
for p in 1..=2u64 {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.and(query_param("page", p.to_string()))
|
||||
// 10 rows returned against a much larger requested page size.
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(page(10, p, 2)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
|
||||
let all = client(&server)
|
||||
.list_all_clients(&ListParams::new().per_page(9999))
|
||||
.await
|
||||
.expect("should keep paging past a server-capped page");
|
||||
assert_eq!(
|
||||
all.len(),
|
||||
20,
|
||||
"a short page must not be read as the last page when metadata says otherwise"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn per_page_is_clamped_to_the_server_maximum() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.and(query_param("per_page", "5000"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
client(&server)
|
||||
.list_all_clients(&ListParams::new().per_page(100_000))
|
||||
.await
|
||||
.expect("oversized per_page should be clamped to 5000, not sent verbatim");
|
||||
}
|
||||
|
||||
/// `per_page(0)` made the old loop compare `got < 0`, which nothing
|
||||
/// satisfies — it paged forever. It must fall back to the default size.
|
||||
#[tokio::test]
|
||||
async fn zero_per_page_does_not_spin() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.and(query_param("per_page", "50"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let all = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
client(&server).list_all_clients(&ListParams::new().per_page(0)),
|
||||
)
|
||||
.await
|
||||
.expect("must terminate rather than page forever")
|
||||
.expect("request should succeed");
|
||||
assert!(all.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_page_terminates_even_if_metadata_claims_more() {
|
||||
let server = MockServer::start().await;
|
||||
// Metadata insists there are 99 pages, but the first comes back empty.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 99)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let all = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
client(&server).list_all_clients(&ListParams::new()),
|
||||
)
|
||||
.await
|
||||
.expect("an empty page must end the walk")
|
||||
.expect("request should succeed");
|
||||
assert!(all.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_falls_back_to_short_page_without_metadata() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.and(query_param("page", "1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": (0..50).map(|i| json!({ "id": format!("id{i}") })).collect::<Vec<_>>()
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.and(query_param("page", "2"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"data": [{ "id": "last" }]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let all = client(&server)
|
||||
.list_all_clients(&ListParams::new())
|
||||
.await
|
||||
.expect("should page without metadata");
|
||||
assert_eq!(all.len(), 51);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_honours_a_starting_page() {
|
||||
let server = MockServer::start().await;
|
||||
for p in 2..=3u64 {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.and(query_param("page", p.to_string()))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(page(50, p, 3)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
|
||||
let all = client(&server)
|
||||
.list_all_clients(&ListParams::new().page(2))
|
||||
.await
|
||||
.expect("should start at the requested page");
|
||||
assert_eq!(all.len(), 100, "pages 2 and 3 only");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_params_are_sent_on_every_page() {
|
||||
let server = MockServer::start().await;
|
||||
for p in 1..=2u64 {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.and(query_param("page", p.to_string()))
|
||||
.and(query_param("client_id", "abc"))
|
||||
.and(query_param("status", "active"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(page(50, p, 2)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
|
||||
let all = client(&server)
|
||||
.list_all_clients(&ListParams::new().status("active").param("client_id", "abc"))
|
||||
.await
|
||||
.expect("filters must survive pagination");
|
||||
assert_eq!(all.len(), 100);
|
||||
}
|
||||
|
||||
// --- error mapping ------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_errors_keep_the_whole_field_map() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/clients"))
|
||||
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
|
||||
"message": "The given data was invalid.",
|
||||
"errors": {
|
||||
"name": ["The name field is required."],
|
||||
"contacts.0.email": ["The email must be valid.", "Already taken."]
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client(&server)
|
||||
.create_client(&Default::default())
|
||||
.await
|
||||
.expect_err("422 should surface as Error::Api");
|
||||
|
||||
match err {
|
||||
Error::Api {
|
||||
status,
|
||||
message,
|
||||
errors,
|
||||
} => {
|
||||
assert_eq!(status, 422);
|
||||
assert_eq!(message, "The given data was invalid.");
|
||||
assert_eq!(errors["name"], vec!["The name field is required."]);
|
||||
assert_eq!(
|
||||
errors["contacts.0.email"],
|
||||
vec!["The email must be valid.", "Already taken."],
|
||||
"every message is kept, not just the first"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Error::Api, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_json_error_body_falls_back_to_the_raw_text() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.respond_with(ResponseTemplate::new(502).set_body_string("<html>bad gateway</html>"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client(&server)
|
||||
.list_clients(&ListParams::new())
|
||||
.await
|
||||
.expect_err("502 should surface as Error::Api");
|
||||
|
||||
match err {
|
||||
Error::Api {
|
||||
status, message, ..
|
||||
} => {
|
||||
assert_eq!(status, 502);
|
||||
assert!(
|
||||
message.contains("bad gateway"),
|
||||
"a non-JSON body should be preserved verbatim, got {message:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Error::Api, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_reports_api_errors_rather_than_succeeding() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path("/clients/abc"))
|
||||
.respond_with(ResponseTemplate::new(403).set_body_json(json!({ "message": "forbidden" })))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client(&server)
|
||||
.delete_client("abc")
|
||||
.await
|
||||
.expect_err("403 should not read as a successful delete");
|
||||
assert!(matches!(err, Error::Api { status, .. } if status == 403));
|
||||
}
|
||||
|
||||
// --- base url validation ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn base_url_without_a_scheme_is_rejected_at_construction() {
|
||||
let err = InvoiceNinjaClient::new("invoicing.co/api/v1", "t")
|
||||
.expect_err("a schemeless url should fail fast, not 404 on every call");
|
||||
assert!(matches!(err, Error::InvalidBaseUrl(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_http_base_url_is_rejected() {
|
||||
let err = InvoiceNinjaClient::new("ftp://example.com/api/v1", "t")
|
||||
.expect_err("only http(s) is usable");
|
||||
assert!(matches!(err, Error::InvalidBaseUrl(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_base_url_is_rejected() {
|
||||
let err = InvoiceNinjaClient::new(" ", "t").expect_err("empty base url should fail");
|
||||
assert!(matches!(err, Error::InvalidBaseUrl(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_slashes_are_tolerated() {
|
||||
InvoiceNinjaClient::new("https://example.com/api/v1/", "t")
|
||||
.expect("a trailing slash should not be an error");
|
||||
}
|
||||
|
||||
// --- timeouts -----------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn requests_give_up_once_the_timeout_elapses() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/clients"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(page(0, 1, 1))
|
||||
.set_delay(Duration::from_secs(30)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = client(&server).with_timeout(Duration::from_millis(150));
|
||||
let err = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
client.list_clients(&ListParams::new()),
|
||||
)
|
||||
.await
|
||||
.expect("the client's own timeout should fire well before this one")
|
||||
.expect_err("a stalled response must not hang the caller");
|
||||
|
||||
assert!(
|
||||
matches!(&err, Error::Http(e) if e.is_timeout()),
|
||||
"expected a timeout error, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- envelope handling --------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_response_without_a_data_key_reads_as_empty() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/invoices/bulk"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(json!({ "message": "No Invoices Found" })),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let got = client(&server)
|
||||
.bulk_invoices("archive", &["missing"])
|
||||
.await
|
||||
.expect("a no-match bulk action is not an error");
|
||||
assert!(got.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_accepts_an_empty_success_body() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ping"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
client(&server).ping().await.expect("ping should succeed");
|
||||
}
|
||||
+545
-1
@@ -1,5 +1,9 @@
|
||||
use invoiceninjars::models::{
|
||||
Client, Credit, Expense, Invoice, ListParams, Project, Quote, Task, Vendor,
|
||||
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
|
||||
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
|
||||
GroupSetting, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project, PurchaseOrder,
|
||||
Quote, RecurringExpense, RecurringInvoice, RecurringQuote, Subscription, SystemLog, Tag, Task,
|
||||
TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -150,3 +154,543 @@ fn quote_and_credit_share_the_invoice_shape() {
|
||||
let credit: Credit = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(credit.number, "QUO-0001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurring_invoice_decodes_recurrence_fields() {
|
||||
// frequency_id / remaining_cycles come back as JSON numbers, and
|
||||
// remaining_cycles is -1 for an indefinite schedule.
|
||||
let payload = json!({
|
||||
"id": "ri1",
|
||||
"client_id": "abc123",
|
||||
"frequency_id": 5,
|
||||
"remaining_cycles": -1,
|
||||
"auto_bill": "off",
|
||||
"next_send_date": "2026-11-01",
|
||||
"line_items": [{"product_key": "widget", "cost": 9.5, "quantity": 2.0}],
|
||||
"custom_value1": "unmodeled field",
|
||||
});
|
||||
|
||||
let ri: RecurringInvoice = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(ri.frequency_id, 5);
|
||||
assert_eq!(ri.remaining_cycles, -1);
|
||||
assert_eq!(ri.next_send_date, "2026-11-01");
|
||||
assert_eq!(ri.line_items.len(), 1);
|
||||
assert_eq!(ri.line_items[0].product_key, "widget");
|
||||
|
||||
let round_tripped = serde_json::to_value(&ri).unwrap();
|
||||
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
|
||||
}
|
||||
|
||||
/// `RecurringQuote` aliases `RecurringInvoice`; the API's RecurringQuote
|
||||
/// fields are a strict subset, so a quote payload must decode without the
|
||||
/// invoice-only fields present.
|
||||
#[test]
|
||||
fn recurring_quote_decodes_without_invoice_only_fields() {
|
||||
let payload = json!({
|
||||
"id": "rq1",
|
||||
"client_id": "abc123",
|
||||
"frequency_id": 2,
|
||||
"remaining_cycles": 12,
|
||||
});
|
||||
|
||||
let rq: RecurringQuote = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(rq.frequency_id, 2);
|
||||
assert_eq!(rq.remaining_cycles, 12);
|
||||
// Invoice-only fields fall back to their defaults rather than failing.
|
||||
assert_eq!(rq.auto_bill, "");
|
||||
assert!(!rq.auto_bill_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchase_order_round_trip_preserves_unknown_fields() {
|
||||
let payload = json!({
|
||||
"id": "po1",
|
||||
"vendor_id": "ven1",
|
||||
"number": "PO-0001",
|
||||
"amount": 250.0,
|
||||
"custom_surcharge1": 7.25,
|
||||
});
|
||||
|
||||
let po: PurchaseOrder = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(po.vendor_id, "ven1");
|
||||
assert_eq!(po.number, "PO-0001");
|
||||
assert_eq!(po.amount, 250.0);
|
||||
|
||||
let round_tripped = serde_json::to_value(&po).unwrap();
|
||||
assert_eq!(round_tripped["custom_surcharge1"], 7.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurring_expense_round_trip_preserves_unknown_fields() {
|
||||
let payload = json!({
|
||||
"id": "rex1",
|
||||
"vendor_id": "ven1",
|
||||
"frequency_id": 4,
|
||||
"remaining_cycles": 6,
|
||||
"amount": 19.99,
|
||||
"custom_value2": "unmodeled field",
|
||||
});
|
||||
|
||||
let rex: RecurringExpense = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(rex.frequency_id, 4);
|
||||
assert_eq!(rex.remaining_cycles, 6);
|
||||
assert_eq!(rex.amount, 19.99);
|
||||
|
||||
let round_tripped = serde_json::to_value(&rex).unwrap();
|
||||
assert_eq!(round_tripped["custom_value2"], "unmodeled field");
|
||||
}
|
||||
|
||||
/// `Subscription.frequency_id` is a string even though the recurring-invoice
|
||||
/// field of the same name is numeric; this pins that asymmetry.
|
||||
#[test]
|
||||
fn subscription_frequency_id_is_a_string() {
|
||||
let payload = json!({
|
||||
"id": "sub1",
|
||||
"product_id": "prod1",
|
||||
"frequency_id": "5",
|
||||
"trial_enabled": true,
|
||||
"trial_duration": 14,
|
||||
"promo_discount": 10.0,
|
||||
});
|
||||
|
||||
let sub: Subscription = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(sub.frequency_id, "5");
|
||||
assert!(sub.trial_enabled);
|
||||
assert_eq!(sub.trial_duration, 14);
|
||||
assert_eq!(sub.promo_discount, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_lookup_resources_decode() {
|
||||
let tax_rate: TaxRate = serde_json::from_value(json!({
|
||||
"id": "tr1", "name": "GST", "rate": 10.0
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(tax_rate.name, "GST");
|
||||
assert_eq!(tax_rate.rate, 10.0);
|
||||
|
||||
let category: ExpenseCategory = serde_json::from_value(json!({
|
||||
"id": "ec1", "name": "Accounting"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(category.name, "Accounting");
|
||||
|
||||
let status: TaskStatus = serde_json::from_value(json!({
|
||||
"id": "ts1", "name": "Backlog", "color": "#000000", "task_status_order": 4
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(status.task_status_order, 4);
|
||||
assert_eq!(status.color, "#000000");
|
||||
|
||||
let term: PaymentTerm = serde_json::from_value(json!({
|
||||
"id": "pt1", "name": "NET 30", "num_days": 30
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(term.num_days, 30);
|
||||
|
||||
let webhook: Webhook = serde_json::from_value(json!({
|
||||
"id": "wh1", "event_id": "1", "target_url": "https://example.com/hook", "format": "JSON"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(webhook.target_url, "https://example.com/hook");
|
||||
assert_eq!(webhook.format, "JSON");
|
||||
}
|
||||
|
||||
/// Bank transactions are the one resource whose ids come back as integers
|
||||
/// rather than hashed strings.
|
||||
#[test]
|
||||
fn bank_transaction_decodes_integer_ids() {
|
||||
let payload = json!({
|
||||
"id": "bt1",
|
||||
"transaction_id": 9876543210i64,
|
||||
"bank_account_id": 42,
|
||||
"category_id": 7,
|
||||
"amount": 123.45,
|
||||
"base_type": "CREDIT",
|
||||
"date": "2026-10-01",
|
||||
});
|
||||
|
||||
let bt: BankTransaction = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(bt.transaction_id, 9876543210);
|
||||
assert_eq!(bt.bank_account_id, 42);
|
||||
assert_eq!(bt.category_id, 7);
|
||||
assert_eq!(bt.base_type, "CREDIT");
|
||||
}
|
||||
|
||||
/// A newly constructed model must not serialize an `id`, so `create_*` calls
|
||||
/// do not send `"id": null` to the API.
|
||||
#[test]
|
||||
fn default_models_omit_id_on_serialize() {
|
||||
let body = serde_json::to_value(RecurringInvoice::default()).unwrap();
|
||||
assert!(body.get("id").is_none());
|
||||
|
||||
let body = serde_json::to_value(TaxRate::default()).unwrap();
|
||||
assert!(body.get("id").is_none());
|
||||
}
|
||||
|
||||
// --- explicit-null tolerance -------------------------------------------
|
||||
//
|
||||
// Invoice Ninja returns `null` for plenty of nominally-string fields.
|
||||
// `#[serde(default)]` only covers a *missing* key, so these payloads used to
|
||||
// fail to decode entirely; the models pair `default` with a
|
||||
// null-to-default deserializer.
|
||||
|
||||
#[test]
|
||||
fn null_strings_numbers_and_bools_decode_as_defaults() {
|
||||
let payload = json!({
|
||||
"id": "abc123",
|
||||
"name": null,
|
||||
"address1": null,
|
||||
"balance": null,
|
||||
"is_deleted": null,
|
||||
"contacts": null,
|
||||
});
|
||||
|
||||
let client: Client = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(client.name, "");
|
||||
assert_eq!(client.address1, "");
|
||||
assert_eq!(client.balance, 0.0);
|
||||
assert!(!client.is_deleted);
|
||||
assert!(client.contacts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_line_items_and_nested_nulls_decode() {
|
||||
let payload = json!({
|
||||
"id": "inv1",
|
||||
"number": null,
|
||||
"public_notes": null,
|
||||
"line_items": [{
|
||||
"product_key": null,
|
||||
"notes": null,
|
||||
"cost": null,
|
||||
"quantity": 3.0,
|
||||
}],
|
||||
});
|
||||
|
||||
let invoice: Invoice = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(invoice.number, "");
|
||||
assert_eq!(invoice.public_notes, "");
|
||||
assert_eq!(invoice.line_items.len(), 1);
|
||||
assert_eq!(invoice.line_items[0].product_key, "");
|
||||
assert_eq!(invoice.line_items[0].cost, 0.0);
|
||||
assert_eq!(invoice.line_items[0].quantity, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_integer_fields_decode_across_resources() {
|
||||
let ri: RecurringInvoice = serde_json::from_value(json!({
|
||||
"id": "ri1", "frequency_id": null, "remaining_cycles": null, "auto_bill": null
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(ri.frequency_id, 0);
|
||||
assert_eq!(ri.remaining_cycles, 0);
|
||||
assert_eq!(ri.auto_bill, "");
|
||||
|
||||
let status: TaskStatus = serde_json::from_value(json!({
|
||||
"id": "ts1", "name": null, "task_status_order": null
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(status.task_status_order, 0);
|
||||
|
||||
let bt: BankTransaction = serde_json::from_value(json!({
|
||||
"id": "bt1", "transaction_id": null, "bank_account_id": null, "base_type": null
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(bt.transaction_id, 0);
|
||||
assert_eq!(bt.bank_account_id, 0);
|
||||
}
|
||||
|
||||
/// Nullable timestamps stay `None` rather than collapsing to `Some(0)`, so
|
||||
/// "never archived" is still distinguishable from "archived at epoch".
|
||||
#[test]
|
||||
fn null_timestamps_remain_none() {
|
||||
let client: Client = serde_json::from_value(json!({
|
||||
"id": "abc123", "archived_at": null, "created_at": null, "updated_at": 1700000000i64
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(client.archived_at, None);
|
||||
assert_eq!(client.created_at, None);
|
||||
assert_eq!(client.updated_at, Some(1700000000));
|
||||
}
|
||||
|
||||
/// A null value for an *unmodeled* key still round-trips through `extra`.
|
||||
#[test]
|
||||
fn null_unknown_fields_round_trip_through_extra() {
|
||||
let payload = json!({"id": "abc123", "name": "Acme Inc", "custom_value1": null});
|
||||
|
||||
let client: Client = serde_json::from_value(payload).unwrap();
|
||||
assert!(client.extra.get("custom_value1").unwrap().is_null());
|
||||
|
||||
let round_tripped = serde_json::to_value(&client).unwrap();
|
||||
assert!(round_tripped["custom_value1"].is_null());
|
||||
}
|
||||
|
||||
// --- non-CRUD endpoint plumbing ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn bulk_request_serializes_action_and_ids() {
|
||||
let body = BulkRequest::new("archive", &["abc123", "def456"]);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&body).unwrap(),
|
||||
json!({"action": "archive", "ids": ["abc123", "def456"]})
|
||||
);
|
||||
}
|
||||
|
||||
/// `BulkRequest::new` takes anything string-like, so ids pulled off models
|
||||
/// (which are `Option<String>`) don't need converting at the call site.
|
||||
#[test]
|
||||
fn bulk_request_accepts_owned_and_borrowed_ids() {
|
||||
let owned: Vec<String> = vec!["abc123".to_string(), "def456".to_string()];
|
||||
let from_owned = BulkRequest::new("delete", &owned);
|
||||
let from_borrowed = BulkRequest::new("delete", &["abc123", "def456"]);
|
||||
|
||||
assert_eq!(from_owned.ids, from_borrowed.ids);
|
||||
assert_eq!(from_owned.action, "delete");
|
||||
}
|
||||
|
||||
/// A bulk action whose `ids` match nothing answers HTTP 200 with
|
||||
/// `{"message": "No Invoices Found"}` — no `data` key at all. That has to
|
||||
/// read as an empty result, not a decode error.
|
||||
#[test]
|
||||
fn list_envelope_without_data_key_decodes_as_empty() {
|
||||
let body: ListResponse<Invoice> =
|
||||
serde_json::from_value(json!({"message": "No Invoices Found"})).unwrap();
|
||||
|
||||
assert!(body.data.is_empty());
|
||||
assert!(body.meta.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_envelope_still_decodes_data_and_pagination() {
|
||||
let body: ListResponse<Invoice> = serde_json::from_value(json!({
|
||||
"data": [{"id": "inv1", "number": "INV-0001"}],
|
||||
"meta": {"pagination": {
|
||||
"total": 1, "count": 1, "per_page": 50, "current_page": 1, "total_pages": 1
|
||||
}},
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(body.data.len(), 1);
|
||||
assert_eq!(body.data[0].number, "INV-0001");
|
||||
let pagination = body.meta.unwrap().pagination.unwrap();
|
||||
assert_eq!(pagination.total, 1);
|
||||
assert_eq!(pagination.per_page, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_round_trip_preserves_unknown_fields() {
|
||||
// `type` is a Rust keyword, so the field is `r#type` but must still map
|
||||
// to the plain JSON key.
|
||||
let payload = json!({
|
||||
"id": "doc1",
|
||||
"name": "contract.pdf",
|
||||
"type": "pdf",
|
||||
"hash": "abc",
|
||||
"is_default": false,
|
||||
"custom_value1": "unmodeled field",
|
||||
});
|
||||
|
||||
let doc: Document = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(doc.name, "contract.pdf");
|
||||
assert_eq!(doc.r#type, "pdf");
|
||||
|
||||
let round_tripped = serde_json::to_value(&doc).unwrap();
|
||||
assert_eq!(round_tripped["type"], "pdf");
|
||||
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
|
||||
}
|
||||
|
||||
// --- list params ------------------------------------------------------
|
||||
|
||||
/// The typed fields serialize; `custom` is skipped here because it is
|
||||
/// applied as a separate query pass (serde_urlencoded can't flatten a map).
|
||||
#[test]
|
||||
fn list_params_skips_custom_in_struct_serialization() {
|
||||
let params = ListParams::new().per_page(25).param("client_id", "abc123");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(¶ms).unwrap(),
|
||||
json!({"per_page": 25})
|
||||
);
|
||||
assert_eq!(
|
||||
params.custom,
|
||||
vec![("client_id".to_string(), "abc123".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_params_custom_pairs_accumulate_and_allow_repeats() {
|
||||
let params = ListParams::new()
|
||||
.param("client_id", "abc123")
|
||||
.param("status", "active")
|
||||
.param("status", "paid");
|
||||
|
||||
assert_eq!(params.custom.len(), 3);
|
||||
assert_eq!(
|
||||
params.custom,
|
||||
vec![
|
||||
("client_id".to_string(), "abc123".to_string()),
|
||||
("status".to_string(), "active".to_string()),
|
||||
("status".to_string(), "paid".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Both halves of the query must survive urlencoding, since the client
|
||||
/// serializes the typed struct and the custom pairs in two passes.
|
||||
#[test]
|
||||
fn list_params_both_halves_urlencode() {
|
||||
let params = ListParams::new().per_page(25).sort("created_at|desc");
|
||||
|
||||
let typed = serde_urlencoded::to_string(¶ms).unwrap();
|
||||
assert!(typed.contains("per_page=25"));
|
||||
assert!(typed.contains("sort=created_at%7Cdesc"));
|
||||
|
||||
let custom = serde_urlencoded::to_string(
|
||||
&ListParams::new()
|
||||
.param("client_id", "abc123")
|
||||
.param("is_deleted", "false")
|
||||
.custom,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(custom, "client_id=abc123&is_deleted=false");
|
||||
}
|
||||
|
||||
// --- newly added resource groups ---------------------------------------
|
||||
|
||||
#[test]
|
||||
fn company_keeps_settings_untyped_and_preserves_toggles() {
|
||||
// Company has well over a hundred fields; only the structural ones are
|
||||
// modeled, and `settings` stays untyped.
|
||||
let payload = json!({
|
||||
"id": "co1",
|
||||
"company_key": "abc",
|
||||
"settings": {"currency_id": "1", "timezone_id": "15"},
|
||||
"enabled_modules": 32767,
|
||||
"show_product_cost": true,
|
||||
"markdown_enabled": true,
|
||||
});
|
||||
|
||||
let company: Company = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(company.company_key, "abc");
|
||||
assert_eq!(company.enabled_modules, 32767);
|
||||
assert!(company.markdown_enabled);
|
||||
assert_eq!(company.settings["currency_id"], "1");
|
||||
// An unmodeled feature toggle still round-trips.
|
||||
let round_tripped = serde_json::to_value(&company).unwrap();
|
||||
assert_eq!(round_tripped["show_product_cost"], true);
|
||||
assert_eq!(round_tripped["settings"]["timezone_id"], "15");
|
||||
}
|
||||
|
||||
/// System logs are the one resource whose timestamps are date strings
|
||||
/// rather than integers.
|
||||
#[test]
|
||||
fn system_log_timestamps_are_strings() {
|
||||
let log: SystemLog = serde_json::from_value(json!({
|
||||
"id": "sl1",
|
||||
"event_id": 30,
|
||||
"category_id": 1,
|
||||
"type_id": 300,
|
||||
"log": {"message": "gateway declined"},
|
||||
"created_at": "2026-10-01 12:00:00",
|
||||
"updated_at": "2026-10-01 12:00:00",
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(log.event_id, 30);
|
||||
assert_eq!(log.created_at, "2026-10-01 12:00:00");
|
||||
assert_eq!(log.log["message"], "gateway declined");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_resource_models_decode() {
|
||||
let integration: BankIntegration = serde_json::from_value(json!({
|
||||
"id": "bi1", "bank_account_id": 7788, "balance": 1234.56, "currency": "USD"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(integration.bank_account_id, 7788);
|
||||
assert_eq!(integration.balance, 1234.56);
|
||||
|
||||
let rule: BankTransactionRule = serde_json::from_value(json!({
|
||||
"id": "btr1",
|
||||
"name": "Coffee",
|
||||
"matches_on_all": true,
|
||||
"rules": [{"search_key": "description", "operator": "contains", "value": "cafe"}],
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(rule.rules.len(), 1);
|
||||
assert_eq!(rule.rules[0]["operator"], "contains");
|
||||
|
||||
let gateway: CompanyGateway = serde_json::from_value(json!({
|
||||
"id": "cg1", "gateway_key": "d14dd26a", "accepted_credit_cards": 32, "config": "encrypted"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(gateway.accepted_credit_cards, 32);
|
||||
assert_eq!(gateway.config, "encrypted");
|
||||
|
||||
let tag: Tag = serde_json::from_value(json!({
|
||||
"id": "t1", "name": "urgent", "color": "#ff0000", "entity_type": "invoice"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(tag.name, "urgent");
|
||||
|
||||
let location: Location = serde_json::from_value(json!({
|
||||
"id": "loc1", "name": "Warehouse", "city": "Brisbane", "is_shipping_location": true
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(location.is_shipping_location);
|
||||
|
||||
let scheduler: TaskScheduler = serde_json::from_value(json!({
|
||||
"id": "ts1", "job": "email_report", "repeat_every": "WEEK",
|
||||
"start_from": 1700000000i64, "report_keys": ["date", "amount"],
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(scheduler.report_keys, vec!["date", "amount"]);
|
||||
|
||||
let token: CompanyToken = serde_json::from_value(json!({
|
||||
"id": "tok1", "name": "CI token", "token": "secret", "is_system": false
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(token.name, "CI token");
|
||||
|
||||
let gw_token: ClientGatewayToken = serde_json::from_value(json!({
|
||||
"id": "cgt1", "client_id": "abc123", "token": "card_x", "is_default": true
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(gw_token.is_default);
|
||||
|
||||
let group: GroupSetting = serde_json::from_value(json!({
|
||||
"id": "gs1", "name": "Wholesale", "settings": {"currency_id": "2"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(group.settings["currency_id"], "2");
|
||||
|
||||
let design: Design = serde_json::from_value(json!({
|
||||
"id": "d1", "name": "Clean", "is_custom": false, "is_active": true
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(design.is_active);
|
||||
|
||||
let user: User = serde_json::from_value(json!({
|
||||
"id": "u1", "first_name": "Ada", "email": "ada@example.com", "passkey_count": 2
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(user.first_name, "Ada");
|
||||
assert_eq!(user.passkey_count, 2);
|
||||
}
|
||||
|
||||
/// `Design.design` is typed as a string upstream but returned as a nested
|
||||
/// object, so it is left unmodeled and must survive in `extra`.
|
||||
#[test]
|
||||
fn design_body_round_trips_through_extra() {
|
||||
let payload = json!({
|
||||
"id": "d1",
|
||||
"name": "Custom",
|
||||
"design": {"header": "<h1>", "body": "<div>", "footer": "<p>"},
|
||||
});
|
||||
|
||||
let design: Design = serde_json::from_value(payload).unwrap();
|
||||
let round_tripped = serde_json::to_value(&design).unwrap();
|
||||
assert_eq!(round_tripped["design"]["header"], "<h1>");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user