use invoiceninjars::models::{ 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; #[test] fn client_round_trip_preserves_unknown_fields() { let payload = json!({ "id": "abc123", "name": "Acme Inc", "custom_value1": "unmodeled field", }); let client: Client = serde_json::from_value(payload).unwrap(); assert_eq!(client.name, "Acme Inc"); assert_eq!( client.extra.get("custom_value1").unwrap(), "unmodeled field" ); let round_tripped = serde_json::to_value(&client).unwrap(); assert_eq!(round_tripped["custom_value1"], "unmodeled field"); } #[test] fn invoice_round_trip_preserves_unknown_fields() { let payload = json!({ "id": "inv1", "client_id": "abc123", "number": "INV-0001", "custom_surcharge1": 12.5, }); let invoice: Invoice = serde_json::from_value(payload).unwrap(); assert_eq!(invoice.number, "INV-0001"); assert_eq!(invoice.extra.get("custom_surcharge1").unwrap(), 12.5); let round_tripped = serde_json::to_value(&invoice).unwrap(); assert_eq!(round_tripped["custom_surcharge1"], 12.5); } #[test] fn list_params_omits_unset_fields() { let params = ListParams::new().per_page(25).sort("created_at|desc"); assert_eq!( serde_json::to_value(¶ms).unwrap(), json!({"per_page": 25, "sort": "created_at|desc"}) ); } #[test] fn vendor_round_trip_preserves_nested_contact_fields() { let payload = json!({ "id": "ven1", "name": "Supply Co", "custom_value2": "unmodeled vendor field", "contacts": [{ "id": "vc1", "first_name": "Ada", "email": "ada@supply.example", "is_primary": true, "custom_value1": "unmodeled contact field", }], }); let vendor: Vendor = serde_json::from_value(payload).unwrap(); assert_eq!(vendor.name, "Supply Co"); assert_eq!(vendor.contacts.len(), 1); assert_eq!(vendor.contacts[0].first_name, "Ada"); assert!(vendor.contacts[0].is_primary); let round_tripped = serde_json::to_value(&vendor).unwrap(); assert_eq!(round_tripped["custom_value2"], "unmodeled vendor field"); assert_eq!( round_tripped["contacts"][0]["custom_value1"], "unmodeled contact field" ); } #[test] fn expense_round_trip_preserves_unknown_fields() { let payload = json!({ "id": "exp1", "vendor_id": "ven1", "amount": 42.75, "should_be_invoiced": true, "custom_value3": "unmodeled field", }); let expense: Expense = serde_json::from_value(payload).unwrap(); assert_eq!(expense.vendor_id, "ven1"); assert_eq!(expense.amount, 42.75); assert!(expense.should_be_invoiced); let round_tripped = serde_json::to_value(&expense).unwrap(); assert_eq!(round_tripped["custom_value3"], "unmodeled field"); } #[test] fn task_round_trip_preserves_unknown_fields() { let payload = json!({ "id": "task1", "project_id": "proj1", "description": "Design review", "time_log": "[[1700000000,1700003600]]", "custom_value1": "unmodeled field", }); let task: Task = serde_json::from_value(payload).unwrap(); assert_eq!(task.project_id, "proj1"); assert_eq!(task.time_log, "[[1700000000,1700003600]]"); let round_tripped = serde_json::to_value(&task).unwrap(); assert_eq!(round_tripped["custom_value1"], "unmodeled field"); } #[test] fn project_round_trip_preserves_unknown_fields() { let payload = json!({ "id": "proj1", "client_id": "abc123", "name": "Website rebuild", "budgeted_hours": 120.0, "custom_value4": "unmodeled field", }); let project: Project = serde_json::from_value(payload).unwrap(); assert_eq!(project.name, "Website rebuild"); assert_eq!(project.budgeted_hours, 120.0); let round_tripped = serde_json::to_value(&project).unwrap(); assert_eq!(round_tripped["custom_value4"], "unmodeled field"); } /// `Quote` and `Credit` are type aliases for `Invoice`; this pins that the /// aliased endpoints decode the shared invoice shape. #[test] fn quote_and_credit_share_the_invoice_shape() { let payload = json!({ "id": "q1", "number": "QUO-0001", "entity_type": "quote", }); let quote: Quote = serde_json::from_value(payload.clone()).unwrap(); assert_eq!(quote.number, "QUO-0001"); assert_eq!(quote.extra.get("entity_type").unwrap(), "quote"); 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`) don't need converting at the call site. #[test] fn bulk_request_accepts_owned_and_borrowed_ids() { let owned: Vec = 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 = 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 = 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": "

", "body": "
", "footer": "

"}, }); let design: Design = serde_json::from_value(payload).unwrap(); let round_tripped = serde_json::to_value(&design).unwrap(); assert_eq!(round_tripped["design"]["header"], "

"); }