From bd2d34cae98a25f78467c159903ea9d97a0563d4 Mon Sep 17 00:00:00 2001 From: Andrew Bryant Date: Sat, 24 Oct 2026 21:55:46 -0400 Subject: [PATCH] Correct the models against what a real server actually sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/models/location.rs | 9 ++++++++- src/models/payment.rs | 2 ++ src/models/purchase_order.rs | 9 ++++++++- src/models/recurring_expense.rs | 2 +- src/models/recurring_invoice.rs | 5 +++-- src/models/system_log.rs | 9 +++++---- src/models/webhook.rs | 9 +++++++-- tests/model_roundtrip.rs | 13 +++++++------ 8 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/models/location.rs b/src/models/location.rs index f36ddb9..225acb7 100644 --- a/src/models/location.rs +++ b/src/models/location.rs @@ -18,7 +18,14 @@ pub struct Location { pub state: String, #[serde(default, deserialize_with = "super::common::null_to_default")] 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, #[serde(default, deserialize_with = "super::common::null_to_default")] pub user_id: String, diff --git a/src/models/payment.rs b/src/models/payment.rs index f776d2c..c4cb27c 100644 --- a/src/models/payment.rs +++ b/src/models/payment.rs @@ -38,7 +38,9 @@ pub struct Payment { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct PaymentInvoice { + #[serde(default, deserialize_with = "super::common::null_to_default")] pub invoice_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] pub amount: f64, #[serde(flatten)] diff --git a/src/models/purchase_order.rs b/src/models/purchase_order.rs index 29e7e65..c2b63f0 100644 --- a/src/models/purchase_order.rs +++ b/src/models/purchase_order.rs @@ -23,7 +23,14 @@ pub struct PurchaseOrder { pub po_number: String, #[serde(default, deserialize_with = "super::common::null_to_default")] 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, #[serde(default, deserialize_with = "super::common::null_to_default")] pub date: String, diff --git a/src/models/recurring_expense.rs b/src/models/recurring_expense.rs index b35ee22..e26c035 100644 --- a/src/models/recurring_expense.rs +++ b/src/models/recurring_expense.rs @@ -28,7 +28,7 @@ pub struct RecurringExpense { /// Integer enum matching [`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, /// Number of cycles left; `-1` means it recurs indefinitely. #[serde(default, deserialize_with = "super::common::null_to_default")] diff --git a/src/models/recurring_invoice.rs b/src/models/recurring_invoice.rs index ff93719..16431a3 100644 --- a/src/models/recurring_invoice.rs +++ b/src/models/recurring_invoice.rs @@ -25,8 +25,9 @@ pub struct RecurringInvoice { #[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")] + /// enum (1 = daily, 2 = weekly, … ), not a human-readable string. A real + /// server sends it as a numeric string; both decode. + #[serde(default, deserialize_with = "super::common::int_or_numeric_string")] pub frequency_id: i64, /// Number of cycles left to send; `-1` means it recurs indefinitely. #[serde(default, deserialize_with = "super::common::null_to_default")] diff --git a/src/models/system_log.rs b/src/models/system_log.rs index 922ba95..4d13f8f 100644 --- a/src/models/system_log.rs +++ b/src/models/system_log.rs @@ -3,8 +3,9 @@ use serde_json::{Map, Value}; /// A server-side log entry (gateway responses, mail failures, and so on). /// -/// Note that unlike every other resource, this one's `created_at` and -/// `updated_at` are returned as date **strings**, not integer timestamps. +/// The OpenAPI spec declares `created_at` and `updated_at` as date strings, +/// but a real server (5.13.50) sends integer timestamps like every other +/// resource. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SystemLog { #[serde(skip_serializing_if = "Option::is_none")] @@ -23,9 +24,9 @@ pub struct SystemLog { #[serde(default)] pub log: Value, #[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")] - pub updated_at: String, + pub updated_at: i64, #[serde(flatten)] pub extra: Map, diff --git a/src/models/webhook.rs b/src/models/webhook.rs index 292cea4..c933423 100644 --- a/src/models/webhook.rs +++ b/src/models/webhook.rs @@ -13,8 +13,13 @@ pub struct Webhook { /// The URL Invoice Ninja posts to. #[serde(default, deserialize_with = "super::common::null_to_default")] pub target_url: String, - /// Payload format: `JSON` or `UBL`. - #[serde(default, deserialize_with = "super::common::null_to_default")] + /// Payload format: `JSON` or `UBL`. Omitted when empty: the server turns + /// `""` 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, #[serde(flatten)] diff --git a/tests/model_roundtrip.rs b/tests/model_roundtrip.rs index 75e05a2..40e343f 100644 --- a/tests/model_roundtrip.rs +++ b/tests/model_roundtrip.rs @@ -584,23 +584,24 @@ fn company_keeps_settings_untyped_and_preserves_toggles() { assert_eq!(round_tripped["settings"]["timezone_id"], "15"); } -/// System logs are the one resource whose timestamps are date strings -/// rather than integers. +/// The spec says system-log timestamps are date strings; a real server sends +/// integers, as it does everywhere else. #[test] -fn system_log_timestamps_are_strings() { +fn system_log_timestamps_are_integers() { 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", + "created_at": 1792869488, + "updated_at": 1792869489, })) .unwrap(); 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"); }