Correct the models against what a real server actually sends
Every one of these was wrong in a way the mock tests could not see, because the mocks were written from the same upstream source the models were. - `SystemLog.created_at`/`updated_at` are integer timestamps, not the date strings the OpenAPI spec declares. - `frequency_id` arrives as a numeric string on recurring invoices and expenses, so both now decode through `int_or_numeric_string`. - `Location.country_id`, `PurchaseOrder.status_id` and `Webhook.format` are omitted when empty. The server runs `ConvertEmptyStringsToNull`, so an unset `String` sent as `""` becomes NULL and then fails a non-null column or an `integer` rule — which meant a create from a `Default` model could not succeed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,14 @@ pub struct Location {
|
|||||||
pub state: String,
|
pub state: String,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub postal_code: String,
|
pub postal_code: String,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
/// Omitted when empty: the server validates it as an integer, so an unset
|
||||||
|
/// `""` is rejected with a 422 and a default `Location` could not be
|
||||||
|
/// created.
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "super::common::null_to_default",
|
||||||
|
skip_serializing_if = "String::is_empty"
|
||||||
|
)]
|
||||||
pub country_id: String,
|
pub country_id: String,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ pub struct Payment {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct PaymentInvoice {
|
pub struct PaymentInvoice {
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub invoice_id: String,
|
pub invoice_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub amount: f64,
|
pub amount: f64,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ pub struct PurchaseOrder {
|
|||||||
pub po_number: String,
|
pub po_number: String,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub quote_number: String,
|
pub quote_number: String,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
/// Omitted when empty: the server turns `""` into `NULL` and then fails
|
||||||
|
/// the insert on a non-null column with a 500, so a new purchase order
|
||||||
|
/// must leave it out and take the server's default.
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "super::common::null_to_default",
|
||||||
|
skip_serializing_if = "String::is_empty"
|
||||||
|
)]
|
||||||
pub status_id: String,
|
pub status_id: String,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub date: String,
|
pub date: String,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub struct RecurringExpense {
|
|||||||
/// Integer enum matching [`RecurringInvoice::frequency_id`].
|
/// Integer enum matching [`RecurringInvoice::frequency_id`].
|
||||||
///
|
///
|
||||||
/// [`RecurringInvoice::frequency_id`]: super::RecurringInvoice::frequency_id
|
/// [`RecurringInvoice::frequency_id`]: super::RecurringInvoice::frequency_id
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||||
pub frequency_id: i64,
|
pub frequency_id: i64,
|
||||||
/// Number of cycles left; `-1` means it recurs indefinitely.
|
/// Number of cycles left; `-1` means it recurs indefinitely.
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ pub struct RecurringInvoice {
|
|||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub status_id: String,
|
pub status_id: String,
|
||||||
/// How often the invoice recurs. Invoice Ninja returns this as an integer
|
/// How often the invoice recurs. Invoice Ninja returns this as an integer
|
||||||
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string.
|
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string. A real
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
/// server sends it as a numeric string; both decode.
|
||||||
|
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||||
pub frequency_id: i64,
|
pub frequency_id: i64,
|
||||||
/// Number of cycles left to send; `-1` means it recurs indefinitely.
|
/// Number of cycles left to send; `-1` means it recurs indefinitely.
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ use serde_json::{Map, Value};
|
|||||||
|
|
||||||
/// A server-side log entry (gateway responses, mail failures, and so on).
|
/// A server-side log entry (gateway responses, mail failures, and so on).
|
||||||
///
|
///
|
||||||
/// Note that unlike every other resource, this one's `created_at` and
|
/// The OpenAPI spec declares `created_at` and `updated_at` as date strings,
|
||||||
/// `updated_at` are returned as date **strings**, not integer timestamps.
|
/// but a real server (5.13.50) sends integer timestamps like every other
|
||||||
|
/// resource.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct SystemLog {
|
pub struct SystemLog {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -23,9 +24,9 @@ pub struct SystemLog {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub log: Value,
|
pub log: Value,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub created_at: String,
|
pub created_at: i64,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub updated_at: String,
|
pub updated_at: i64,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub extra: Map<String, Value>,
|
pub extra: Map<String, Value>,
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ pub struct Webhook {
|
|||||||
/// The URL Invoice Ninja posts to.
|
/// The URL Invoice Ninja posts to.
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub target_url: String,
|
pub target_url: String,
|
||||||
/// Payload format: `JSON` or `UBL`.
|
/// Payload format: `JSON` or `UBL`. Omitted when empty: the server turns
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
/// `""` into `NULL` and fails the insert on a non-null column (a 500).
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "super::common::null_to_default",
|
||||||
|
skip_serializing_if = "String::is_empty"
|
||||||
|
)]
|
||||||
pub format: String,
|
pub format: String,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
|
|||||||
@@ -584,23 +584,24 @@ fn company_keeps_settings_untyped_and_preserves_toggles() {
|
|||||||
assert_eq!(round_tripped["settings"]["timezone_id"], "15");
|
assert_eq!(round_tripped["settings"]["timezone_id"], "15");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// System logs are the one resource whose timestamps are date strings
|
/// The spec says system-log timestamps are date strings; a real server sends
|
||||||
/// rather than integers.
|
/// integers, as it does everywhere else.
|
||||||
#[test]
|
#[test]
|
||||||
fn system_log_timestamps_are_strings() {
|
fn system_log_timestamps_are_integers() {
|
||||||
let log: SystemLog = serde_json::from_value(json!({
|
let log: SystemLog = serde_json::from_value(json!({
|
||||||
"id": "sl1",
|
"id": "sl1",
|
||||||
"event_id": 30,
|
"event_id": 30,
|
||||||
"category_id": 1,
|
"category_id": 1,
|
||||||
"type_id": 300,
|
"type_id": 300,
|
||||||
"log": {"message": "gateway declined"},
|
"log": {"message": "gateway declined"},
|
||||||
"created_at": "2026-10-01 12:00:00",
|
"created_at": 1792869488,
|
||||||
"updated_at": "2026-10-01 12:00:00",
|
"updated_at": 1792869489,
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(log.event_id, 30);
|
assert_eq!(log.event_id, 30);
|
||||||
assert_eq!(log.created_at, "2026-10-01 12:00:00");
|
assert_eq!(log.created_at, 1792869488);
|
||||||
|
assert_eq!(log.updated_at, 1792869489);
|
||||||
assert_eq!(log.log["message"], "gateway declined");
|
assert_eq!(log.log["message"], "gateway declined");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user