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:
2026-10-23 18:08:38 -04:00
co-authored by Claude Opus 5
parent ff9a859d0b
commit 9976249210
44 changed files with 4003 additions and 175 deletions
+92
View File
@@ -0,0 +1,92 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use super::invoice::InvoiceItem;
/// A recurring invoice template. Shares the invoices table with [`Invoice`],
/// plus the recurrence fields (`frequency_id`, `remaining_cycles`,
/// `next_send_date`, auto-billing).
///
/// [`Invoice`]: super::Invoice
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecurringInvoice {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub project_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub po_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub status_id: String,
/// How often the invoice recurs. Invoice Ninja returns this as an integer
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub frequency_id: i64,
/// Number of cycles left to send; `-1` means it recurs indefinitely.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub remaining_cycles: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub auto_bill: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub auto_bill_enabled: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub due_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub next_send_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub last_sent_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub balance: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub paid_to_date: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub discount: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub partial: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub exchange_rate: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_amount_discount: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub uses_inclusive_taxes: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub public_notes: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub terms: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub footer: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub design_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub last_viewed: Option<i64>,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub line_items: Vec<InvoiceItem>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// Recurring quotes share the recurring-invoice schema (the API's
/// `RecurringQuote` fields are a strict subset of `RecurringInvoice`), so the
/// `/recurring_quotes` endpoint returns the same JSON shape.
pub type RecurringQuote = RecurringInvoice;