From 5feb3be806b8e5e529ef47bee9443e3efadc771a Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Fri, 13 Mar 2026 17:24:51 +0000 Subject: [PATCH 1/2] MPT-17888: add first-level field annotations to catalog service models Add typed field annotations to all 15 remaining catalog service model classes, following the same pattern established for Parameter and ParameterGroup. All fields are typed as T | None since RQL select can exclude any field from the API response. Models updated: - Authorization, Item, Listing, PriceList, PriceListItem - PricingPolicy, PricingPolicyAttachment, Term, TermVariant - Document, ItemGroup, Media, Template, Product, UnitOfMeasure Each corresponding test file extended with fixture data and tests for: - Primitive field round-trip via to_dict() - Nested fields returning BaseModel instances - Absent fields raising AttributeError (tested with not hasattr) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../resources/catalog/authorizations.py | 29 +++++++- mpt_api_client/resources/catalog/items.py | 29 +++++++- mpt_api_client/resources/catalog/listings.py | 27 +++++++- .../resources/catalog/price_list_items.py | 47 ++++++++++++- .../resources/catalog/price_lists.py | 27 +++++++- .../resources/catalog/pricing_policies.py | 29 +++++++- .../catalog/pricing_policy_attachments.py | 23 ++++++- .../catalog/product_term_variants.py | 31 ++++++++- .../resources/catalog/product_terms.py | 19 ++++- mpt_api_client/resources/catalog/products.py | 29 +++++++- .../resources/catalog/products_documents.py | 29 +++++++- .../resources/catalog/products_item_groups.py | 27 +++++++- .../resources/catalog/products_media.py | 29 +++++++- .../resources/catalog/products_templates.py | 19 ++++- .../resources/catalog/units_of_measure.py | 15 +++- .../resources/catalog/test_authorizations.py | 45 ++++++++++++ tests/unit/resources/catalog/test_items.py | 46 ++++++++++++- tests/unit/resources/catalog/test_listings.py | 44 ++++++++++++ .../catalog/test_price_list_items.py | 69 +++++++++++++++++++ .../resources/catalog/test_price_lists.py | 44 ++++++++++++ .../catalog/test_pricing_policies.py | 45 ++++++++++++ .../test_pricing_policy_attachments.py | 38 ++++++++++ .../catalog/test_product_term_variants.py | 43 ++++++++++++ .../resources/catalog/test_product_terms.py | 37 ++++++++++ tests/unit/resources/catalog/test_products.py | 46 ++++++++++++- .../catalog/test_products_documents.py | 42 +++++++++++ .../catalog/test_products_item_groups.py | 41 +++++++++++ .../resources/catalog/test_products_media.py | 42 +++++++++++ .../catalog/test_products_templates.py | 37 ++++++++++ .../catalog/test_units_of_measure.py | 35 ++++++++++ 30 files changed, 1046 insertions(+), 17 deletions(-) diff --git a/mpt_api_client/resources/catalog/authorizations.py b/mpt_api_client/resources/catalog/authorizations.py index e49513be..a9756bb9 100644 --- a/mpt_api_client/resources/catalog/authorizations.py +++ b/mpt_api_client/resources/catalog/authorizations.py @@ -6,10 +6,37 @@ ManagedResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel class Authorization(Model): - """Authorization resource.""" + """Authorization resource. + + Attributes: + name: Authorization name. + external_ids: External identifiers for the authorization. + currency: Currency code associated with the authorization. + notes: Additional notes. + product: Reference to the product. + vendor: Reference to the vendor. + owner: Reference to the owner account. + statistics: Authorization statistics. + journal: Journal reference. + eligibility: Eligibility information. + audit: Audit information (created, updated events). + """ + + name: str | None + external_ids: BaseModel | None + currency: str | None + notes: str | None + product: BaseModel | None + vendor: BaseModel | None + owner: BaseModel | None + statistics: BaseModel | None + journal: BaseModel | None + eligibility: BaseModel | None + audit: BaseModel | None class AuthorizationsServiceConfig: diff --git a/mpt_api_client/resources/catalog/items.py b/mpt_api_client/resources/catalog/items.py index 0fb88e41..c171a24f 100644 --- a/mpt_api_client/resources/catalog/items.py +++ b/mpt_api_client/resources/catalog/items.py @@ -6,6 +6,7 @@ ManagedResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.mixins import ( AsyncPublishableMixin, PublishableMixin, @@ -13,7 +14,33 @@ class Item(Model): # noqa: WPS110 - """Item resource.""" + """Item resource. + + Attributes: + name: Item name. + description: Item description. + external_ids: External identifiers for the item. + group: Reference to the item group. + unit: Reference to the unit of measure. + terms: Reference to the terms and conditions. + quantity_not_applicable: Whether quantity is not applicable to this item. + status: Item status. + product: Reference to the product. + parameters: List of parameters associated with this item. + audit: Audit information (created, updated events). + """ + + name: str | None + description: str | None + external_ids: BaseModel | None + group: BaseModel | None + unit: BaseModel | None + terms: BaseModel | None + quantity_not_applicable: bool | None + status: str | None + product: BaseModel | None + parameters: list[BaseModel] | None + audit: BaseModel | None class ItemsServiceConfig: diff --git a/mpt_api_client/resources/catalog/listings.py b/mpt_api_client/resources/catalog/listings.py index 1780a474..ea87a2ac 100644 --- a/mpt_api_client/resources/catalog/listings.py +++ b/mpt_api_client/resources/catalog/listings.py @@ -6,10 +6,35 @@ ManagedResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel class Listing(Model): - """Listing resource.""" + """Listing resource. + + Attributes: + authorization: Reference to the authorization. + product: Reference to the product. + vendor: Reference to the vendor. + seller: Reference to the seller. + price_list: Reference to the associated price list. + primary: Whether this is the primary listing. + notes: Additional notes. + statistics: Listing statistics. + eligibility: Eligibility information. + audit: Audit information (created, updated events). + """ + + authorization: BaseModel | None + product: BaseModel | None + vendor: BaseModel | None + seller: BaseModel | None + price_list: BaseModel | None + primary: bool | None + notes: str | None + statistics: BaseModel | None + eligibility: BaseModel | None + audit: BaseModel | None class ListingsServiceConfig: diff --git a/mpt_api_client/resources/catalog/price_list_items.py b/mpt_api_client/resources/catalog/price_list_items.py index ece16562..479f269a 100644 --- a/mpt_api_client/resources/catalog/price_list_items.py +++ b/mpt_api_client/resources/catalog/price_list_items.py @@ -8,10 +8,55 @@ UpdateMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel class PriceListItem(Model): - """Price List Item resource.""" + """Price List Item resource. + + Attributes: + status: Price list item status. + description: Price list item description. + reason_for_change: Reason for the price change. + unit_lp: Unit list price. + unit_pp: Unit purchase price. + markup: Markup percentage. + margin: Margin percentage. + unit_sp: Unit sell price. + p_px1: Purchase price for 1-year term. + p_px_m: Purchase price for monthly term. + p_px_y: Purchase price for yearly term. + s_px1: Sell price for 1-year term. + s_px_m: Sell price for monthly term. + s_px_y: Sell price for yearly term. + l_px1: List price for 1-year term. + l_px_m: List price for monthly term. + l_px_y: List price for yearly term. + price_list: Reference to the parent price list. + item: Reference to the associated item. + audit: Audit information (created, updated events). + """ + + status: str | None + description: str | None + reason_for_change: str | None + unit_lp: float | None + unit_pp: float | None + markup: float | None + margin: float | None + unit_sp: float | None + p_px1: float | None + p_px_m: float | None + p_px_y: float | None + s_px1: float | None + s_px_m: float | None + s_px_y: float | None + l_px1: float | None + l_px_m: float | None + l_px_y: float | None + price_list: BaseModel | None + item: BaseModel | None + audit: BaseModel | None class PriceListItemsServiceConfig: diff --git a/mpt_api_client/resources/catalog/price_lists.py b/mpt_api_client/resources/catalog/price_lists.py index 9c5eaad1..6a216cd8 100644 --- a/mpt_api_client/resources/catalog/price_lists.py +++ b/mpt_api_client/resources/catalog/price_lists.py @@ -6,6 +6,7 @@ ManagedResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.price_list_items import ( AsyncPriceListItemsService, PriceListItemsService, @@ -13,7 +14,31 @@ class PriceList(Model): - """Price List resource.""" + """Price List resource. + + Attributes: + currency: Currency code for this price list. + precision: Decimal precision for prices. + default_markup: Default markup percentage. + default_margin: Default margin percentage. + notes: Additional notes. + external_ids: External identifiers for the price list. + statistics: Price list statistics. + product: Reference to the associated product. + vendor: Reference to the vendor. + audit: Audit information (created, updated events). + """ + + currency: str | None + precision: int | None + default_markup: float | None + default_margin: float | None + notes: str | None + external_ids: BaseModel | None + statistics: BaseModel | None + product: BaseModel | None + vendor: BaseModel | None + audit: BaseModel | None class PriceListsServiceConfig: diff --git a/mpt_api_client/resources/catalog/pricing_policies.py b/mpt_api_client/resources/catalog/pricing_policies.py index 23b66673..80c16bfe 100644 --- a/mpt_api_client/resources/catalog/pricing_policies.py +++ b/mpt_api_client/resources/catalog/pricing_policies.py @@ -9,6 +9,7 @@ ManagedResourceMixin, ) from mpt_api_client.models import Model, ResourceData +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.pricing_policy_attachments import ( AsyncPricingPolicyAttachmentsService, PricingPolicyAttachmentsService, @@ -16,7 +17,33 @@ class PricingPolicy(Model): - """Pricing policy resource.""" + """Pricing policy resource. + + Attributes: + name: Pricing policy name. + external_ids: External identifiers for the pricing policy. + client: Reference to the client account. + eligibility: Eligibility information. + markup: Markup percentage. + margin: Margin percentage. + notes: Additional notes. + products: List of associated products. + status: Pricing policy status. + statistics: Pricing policy statistics. + audit: Audit information (created, updated events). + """ + + name: str | None + external_ids: BaseModel | None + client: BaseModel | None + eligibility: BaseModel | None + markup: float | None + margin: float | None + notes: str | None + products: list[BaseModel] | None + status: str | None + statistics: BaseModel | None + audit: BaseModel | None class PricingPoliciesServiceConfig: diff --git a/mpt_api_client/resources/catalog/pricing_policy_attachments.py b/mpt_api_client/resources/catalog/pricing_policy_attachments.py index 6362ee16..c414b3c7 100644 --- a/mpt_api_client/resources/catalog/pricing_policy_attachments.py +++ b/mpt_api_client/resources/catalog/pricing_policy_attachments.py @@ -10,10 +10,31 @@ ModifiableResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel class PricingPolicyAttachment(Model): - """Pricing Policy Attachment resource.""" + """Pricing Policy Attachment resource. + + Attributes: + name: Attachment name. + type: Attachment type. + size: File size in bytes. + description: Attachment description. + file_name: Original file name. + content_type: MIME content type of the attachment. + status: Attachment status. + audit: Audit information (created, updated events). + """ + + name: str | None + type: str | None + size: int | None + description: str | None + file_name: str | None + content_type: str | None + status: str | None + audit: BaseModel | None class PricingPolicyAttachmentsServiceConfig: diff --git a/mpt_api_client/resources/catalog/product_term_variants.py b/mpt_api_client/resources/catalog/product_term_variants.py index bee9c300..5c5328c9 100644 --- a/mpt_api_client/resources/catalog/product_term_variants.py +++ b/mpt_api_client/resources/catalog/product_term_variants.py @@ -10,6 +10,7 @@ ModifiableResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.mixins import ( AsyncPublishableMixin, PublishableMixin, @@ -17,7 +18,35 @@ class TermVariant(Model): - """Term variant resource.""" + """Term variant resource. + + Attributes: + type: Variant type. + asset_url: URL to the term variant asset. + language_code: Language code for this variant. + name: Variant name. + description: Variant description. + status: Variant status. + filename: Original file name. + size: File size in bytes. + content_type: MIME content type of the file. + terms_and_conditions: Reference to the parent terms and conditions. + file_id: Identifier of the uploaded file. + audit: Audit information (created, updated events). + """ + + type: str | None + asset_url: str | None + language_code: str | None + name: str | None + description: str | None + status: str | None + filename: str | None + size: int | None + content_type: str | None + terms_and_conditions: BaseModel | None + file_id: str | None + audit: BaseModel | None class TermVariantServiceConfig: diff --git a/mpt_api_client/resources/catalog/product_terms.py b/mpt_api_client/resources/catalog/product_terms.py index 9afd7b5a..611cd5ad 100644 --- a/mpt_api_client/resources/catalog/product_terms.py +++ b/mpt_api_client/resources/catalog/product_terms.py @@ -6,6 +6,7 @@ ManagedResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.mixins import AsyncPublishableMixin, PublishableMixin from mpt_api_client.resources.catalog.product_term_variants import ( AsyncTermVariantService, @@ -14,7 +15,23 @@ class Term(Model): - """Term resource.""" + """Term resource. + + Attributes: + name: Term name. + description: Term description. + display_order: Display order of the term. + status: Term status. + product: Reference to the product. + audit: Audit information (created, updated events). + """ + + name: str | None + description: str | None + display_order: int | None + status: str | None + product: BaseModel | None + audit: BaseModel | None class TermServiceConfig: diff --git a/mpt_api_client/resources/catalog/products.py b/mpt_api_client/resources/catalog/products.py index cfe04dde..b6c6f573 100644 --- a/mpt_api_client/resources/catalog/products.py +++ b/mpt_api_client/resources/catalog/products.py @@ -12,6 +12,7 @@ UpdateFileMixin, ) from mpt_api_client.models import Model, ResourceData +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.mixins import ( AsyncPublishableMixin, PublishableMixin, @@ -51,7 +52,33 @@ class Product(Model): - """Product resource.""" + """Product resource. + + Attributes: + name: Product name. + short_description: Short description of the product. + long_description: Long description of the product. + external_ids: External identifiers for the product. + website: Product website URL. + icon: URL or identifier for the product icon. + status: Product status. + vendor: Reference to the vendor account. + settings: Product settings. + statistics: Product statistics. + audit: Audit information (created, updated events). + """ + + name: str | None + short_description: str | None + long_description: str | None + external_ids: BaseModel | None + website: str | None + icon: str | None + status: str | None + vendor: BaseModel | None + settings: BaseModel | None + statistics: BaseModel | None + audit: BaseModel | None class ProductsServiceConfig: diff --git a/mpt_api_client/resources/catalog/products_documents.py b/mpt_api_client/resources/catalog/products_documents.py index c0f59bae..b3ad483f 100644 --- a/mpt_api_client/resources/catalog/products_documents.py +++ b/mpt_api_client/resources/catalog/products_documents.py @@ -6,6 +6,7 @@ ModifiableResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.mixins import ( AsyncDocumentMixin, DocumentMixin, @@ -13,7 +14,33 @@ class Document(Model): - """Document resource.""" + """Document resource. + + Attributes: + name: Document name. + type: Document type. + description: Document description. + status: Document status. + filename: Original file name. + size: File size in bytes. + content_type: MIME content type of the document. + url: URL to access the document. + language: Language code of the document. + product: Reference to the product. + audit: Audit information (created, updated events). + """ + + name: str | None + type: str | None + description: str | None + status: str | None + filename: str | None + size: int | None + content_type: str | None + url: str | None + language: str | None + product: BaseModel | None + audit: BaseModel | None class DocumentServiceConfig: diff --git a/mpt_api_client/resources/catalog/products_item_groups.py b/mpt_api_client/resources/catalog/products_item_groups.py index 29cfdaa7..b4f76ad4 100644 --- a/mpt_api_client/resources/catalog/products_item_groups.py +++ b/mpt_api_client/resources/catalog/products_item_groups.py @@ -6,10 +6,35 @@ ManagedResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel class ItemGroup(Model): - """Item Group resource.""" + """Item Group resource. + + Attributes: + name: Item group name. + label: Display label for the item group. + description: Item group description. + display_order: Display order of the group. + default: Whether this is the default item group. + multiple: Whether multiple items can be selected from this group. + required: Whether a selection from this group is required. + item_count: Number of items in this group. + product: Reference to the product. + audit: Audit information (created, updated events). + """ + + name: str | None + label: str | None + description: str | None + display_order: int | None + default: bool | None + multiple: bool | None + required: bool | None + item_count: int | None + product: BaseModel | None + audit: BaseModel | None class ItemGroupsServiceConfig: diff --git a/mpt_api_client/resources/catalog/products_media.py b/mpt_api_client/resources/catalog/products_media.py index 767add5d..c4d50181 100644 --- a/mpt_api_client/resources/catalog/products_media.py +++ b/mpt_api_client/resources/catalog/products_media.py @@ -6,6 +6,7 @@ ModifiableResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.mixins import ( AsyncMediaMixin, MediaMixin, @@ -13,7 +14,33 @@ class Media(Model): - """Media resource.""" + """Media resource. + + Attributes: + name: Media name. + type: Media type. + description: Media description. + status: Media status. + filename: Original file name. + size: File size in bytes. + content_type: MIME content type of the media file. + display_order: Display order of the media item. + url: URL to access the media file. + product: Reference to the product. + audit: Audit information (created, updated events). + """ + + name: str | None + type: str | None + description: str | None + status: str | None + filename: str | None + size: int | None + content_type: str | None + display_order: int | None + url: str | None + product: BaseModel | None + audit: BaseModel | None class MediaServiceConfig: diff --git a/mpt_api_client/resources/catalog/products_templates.py b/mpt_api_client/resources/catalog/products_templates.py index 963bc1b7..a052f6ba 100644 --- a/mpt_api_client/resources/catalog/products_templates.py +++ b/mpt_api_client/resources/catalog/products_templates.py @@ -6,10 +6,27 @@ ManagedResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel class Template(Model): - """Template resource.""" + """Template resource. + + Attributes: + name: Template name. + content: Template content. + type: Template type. + default: Whether this is the default template. + product: Reference to the product. + audit: Audit information (created, updated events). + """ + + name: str | None + content: str | None + type: str | None + default: bool | None + product: BaseModel | None + audit: BaseModel | None class TemplatesServiceConfig: diff --git a/mpt_api_client/resources/catalog/units_of_measure.py b/mpt_api_client/resources/catalog/units_of_measure.py index 151e5de1..9e4066d1 100644 --- a/mpt_api_client/resources/catalog/units_of_measure.py +++ b/mpt_api_client/resources/catalog/units_of_measure.py @@ -6,10 +6,23 @@ ManagedResourceMixin, ) from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel class UnitOfMeasure(Model): - """Unit of Measure resource.""" + """Unit of Measure resource. + + Attributes: + description: Unit of measure description. + name: Unit of measure name. + statistics: Unit of measure statistics. + audit: Audit information (created, updated events). + """ + + description: str | None + name: str | None + statistics: BaseModel | None + audit: BaseModel | None class UnitsOfMeasureServiceConfig: diff --git a/tests/unit/resources/catalog/test_authorizations.py b/tests/unit/resources/catalog/test_authorizations.py index a7ab0e28..351ef90e 100644 --- a/tests/unit/resources/catalog/test_authorizations.py +++ b/tests/unit/resources/catalog/test_authorizations.py @@ -1,7 +1,9 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.authorizations import ( AsyncAuthorizationsService, + Authorization, AuthorizationsService, ) @@ -16,6 +18,24 @@ def async_authorizations_service(async_http_client): return AsyncAuthorizationsService(http_client=async_http_client) +@pytest.fixture +def authorization_data(): + return { + "id": "AUT-001", + "name": "My Authorization", + "currency": "USD", + "notes": "Some notes", + "externalIds": {"vendor": "ext-001"}, + "product": {"id": "PRD-001", "name": "My Product"}, + "vendor": {"id": "ACC-001", "name": "Vendor"}, + "owner": {"id": "ACC-002", "name": "Owner"}, + "statistics": {"items": 5}, + "journal": {"id": "JRN-001"}, + "eligibility": {"status": "Eligible"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + @pytest.mark.parametrize("method", ["get", "create", "update", "delete", "iterate"]) def test_mixins_present(authorizations_service, method): result = hasattr(authorizations_service, method) @@ -28,3 +48,28 @@ def test_async_mixins_present(async_authorizations_service, method): result = hasattr(async_authorizations_service, method) assert result is True + + +def test_authorization_primitive_fields(authorization_data): + result = Authorization(authorization_data) + + assert result.to_dict() == authorization_data + + +def test_authorization_nested_base_models(authorization_data): + result = Authorization(authorization_data) + + assert isinstance(result.external_ids, BaseModel) + assert isinstance(result.product, BaseModel) + assert isinstance(result.vendor, BaseModel) + assert isinstance(result.statistics, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_authorization_optional_fields_absent(): + result = Authorization({"id": "AUT-001"}) + + assert result.id == "AUT-001" + assert not hasattr(result, "name") + assert not hasattr(result, "currency") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_items.py b/tests/unit/resources/catalog/test_items.py index 52451c73..bf52688a 100644 --- a/tests/unit/resources/catalog/test_items.py +++ b/tests/unit/resources/catalog/test_items.py @@ -1,6 +1,7 @@ import pytest -from mpt_api_client.resources.catalog.items import AsyncItemsService, ItemsService +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.catalog.items import AsyncItemsService, Item, ItemsService @pytest.fixture @@ -13,6 +14,24 @@ def async_items_service(async_http_client): return AsyncItemsService(http_client=async_http_client) +@pytest.fixture +def item_data(): + return { + "id": "ITM-001", + "name": "My Item", + "description": "Item description", + "externalIds": {"vendor": "ext-001"}, + "group": {"id": "GRP-001", "name": "Group"}, + "unit": {"id": "UOM-001", "name": "Each"}, + "terms": {"id": "TRM-001", "name": "Terms"}, + "quantityNotApplicable": False, + "status": "Active", + "product": {"id": "PRD-001", "name": "My Product"}, + "parameters": [{"id": "PRM-001", "name": "Param"}], + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + @pytest.mark.parametrize( "method", [ @@ -49,3 +68,28 @@ def test_async_mixins_present(async_items_service, method): result = hasattr(async_items_service, method) assert result is True + + +def test_item_primitive_fields(item_data): + result = Item(item_data) + + assert result.to_dict() == item_data + + +def test_item_nested_base_models(item_data): + result = Item(item_data) + + assert isinstance(result.external_ids, BaseModel) + assert isinstance(result.group, BaseModel) + assert isinstance(result.unit, BaseModel) + assert isinstance(result.product, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_item_optional_fields_absent(): + result = Item({"id": "ITM-001"}) + + assert result.id == "ITM-001" + assert not hasattr(result, "name") + assert not hasattr(result, "status") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_listings.py b/tests/unit/resources/catalog/test_listings.py index b048dd94..7d7e04af 100644 --- a/tests/unit/resources/catalog/test_listings.py +++ b/tests/unit/resources/catalog/test_listings.py @@ -1,7 +1,9 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.listings import ( AsyncListingsService, + Listing, ListingsService, ) @@ -16,6 +18,23 @@ def async_listings_service(async_http_client): return AsyncListingsService(http_client=async_http_client) +@pytest.fixture +def listing_data(): + return { + "id": "LST-001", + "authorization": {"id": "AUT-001", "name": "My Auth"}, + "product": {"id": "PRD-001", "name": "My Product"}, + "vendor": {"id": "ACC-001", "name": "Vendor"}, + "seller": {"id": "ACC-002", "name": "Seller"}, + "priceList": {"id": "PRC-001", "currency": "USD"}, + "primary": True, + "notes": "Some notes", + "statistics": {"items": 3}, + "eligibility": {"status": "Eligible"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + @pytest.mark.parametrize("method", ["get", "create", "update", "delete", "iterate"]) def test_mixins_present(listings_service, method): result = hasattr(listings_service, method) @@ -28,3 +47,28 @@ def test_async_mixins_present(async_listings_service, method): result = hasattr(async_listings_service, method) assert result is True + + +def test_listing_primitive_fields(listing_data): + result = Listing(listing_data) + + assert result.to_dict() == listing_data + + +def test_listing_nested_base_models(listing_data): + result = Listing(listing_data) + + assert isinstance(result.authorization, BaseModel) + assert isinstance(result.product, BaseModel) + assert isinstance(result.price_list, BaseModel) + assert isinstance(result.statistics, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_listing_optional_fields_absent(): + result = Listing({"id": "LST-001"}) + + assert result.id == "LST-001" + assert not hasattr(result, "notes") + assert not hasattr(result, "primary") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_price_list_items.py b/tests/unit/resources/catalog/test_price_list_items.py index 4002a948..79f3d20e 100644 --- a/tests/unit/resources/catalog/test_price_list_items.py +++ b/tests/unit/resources/catalog/test_price_list_items.py @@ -1,7 +1,9 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.price_list_items import ( AsyncPriceListItemsService, + PriceListItem, PriceListItemsService, ) @@ -20,6 +22,40 @@ def async_price_list_items_service(async_http_client): ) +@pytest.fixture +def price_list_item_data(): + return { + "id": "PLI-001", + "status": "Active", + "description": "Item description", + "reasonForChange": "Price update", + "priceList": {"id": "PRC-001", "currency": "USD"}, + "item": {"id": "ITM-001", "name": "My Item"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +@pytest.fixture +def price_list_item_price_data(): + return { + "id": "PLI-002", + "unitLP": 100.0, + "unitPP": 80.0, + "markup": 25.0, + "margin": 20.0, + "unitSP": 90.0, + "PPx1": 80.0, + "PPxM": 7.0, + "PPxY": 84.0, + "SPx1": 90.0, + "SPxM": 8.0, + "SPxY": 96.0, + "LPx1": 100.0, + "LPxM": 9.0, + "LPxY": 108.0, + } + + @pytest.fixture def test_endpoint(price_list_items_service): result = price_list_items_service.path == "/public/v1/catalog/price-lists/ITM-0000-0001/items" @@ -48,3 +84,36 @@ def test_async_methods_present(async_price_list_items_service, method): result = hasattr(async_price_list_items_service, method) assert result is True + + +def test_price_list_item_primitive_fields(price_list_item_data): + result = PriceListItem(price_list_item_data) + + assert result.to_dict() == price_list_item_data + + +def test_price_list_item_price_fields(price_list_item_price_data): + result = PriceListItem(price_list_item_price_data) + + assert result.unit_lp == pytest.approx(100.0) + assert result.unit_pp == pytest.approx(80.0) + assert result.unit_sp == pytest.approx(90.0) + assert result.markup == pytest.approx(25.0) + assert result.margin == pytest.approx(20.0) + + +def test_price_list_item_nested_models(price_list_item_data): + result = PriceListItem(price_list_item_data) + + assert isinstance(result.price_list, BaseModel) + assert isinstance(result.item, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_price_list_item_optional_fields_absent(): + result = PriceListItem({"id": "PLI-001"}) + + assert result.id == "PLI-001" + assert not hasattr(result, "status") + assert not hasattr(result, "unit_lp") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_price_lists.py b/tests/unit/resources/catalog/test_price_lists.py index ab377164..ad3e9438 100644 --- a/tests/unit/resources/catalog/test_price_lists.py +++ b/tests/unit/resources/catalog/test_price_lists.py @@ -1,11 +1,13 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.price_list_items import ( AsyncPriceListItemsService, PriceListItemsService, ) from mpt_api_client.resources.catalog.price_lists import ( AsyncPriceListsService, + PriceList, PriceListsService, ) @@ -20,6 +22,23 @@ def async_price_lists_service(http_client): return AsyncPriceListsService(http_client=http_client) +@pytest.fixture +def price_list_data(): + return { + "id": "PRC-001", + "currency": "USD", + "precision": 2, + "defaultMarkup": 25.0, + "defaultMargin": 20.0, + "notes": "Some notes", + "externalIds": {"vendor": "ext-001"}, + "statistics": {"items": 10}, + "product": {"id": "PRD-001", "name": "My Product"}, + "vendor": {"id": "ACC-001", "name": "Vendor"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + @pytest.mark.parametrize("method", ["get", "create", "update", "delete", "iterate"]) def test_mixins_present(price_lists_service, method): result = hasattr(price_lists_service, method) @@ -58,3 +77,28 @@ def test_async_property_services(async_price_lists_service, service_method, expe assert isinstance(result, expected_model_class) assert result.endpoint_params == {"price_list_id": "ITM-0000-0001"} + + +def test_price_list_primitive_fields(price_list_data): + result = PriceList(price_list_data) + + assert result.to_dict() == price_list_data + + +def test_price_list_nested_fields_are_base_models(price_list_data): + result = PriceList(price_list_data) + + assert isinstance(result.external_ids, BaseModel) + assert isinstance(result.statistics, BaseModel) + assert isinstance(result.product, BaseModel) + assert isinstance(result.vendor, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_price_list_optional_fields_absent(): + result = PriceList({"id": "PRC-001"}) + + assert result.id == "PRC-001" + assert not hasattr(result, "currency") + assert not hasattr(result, "precision") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_pricing_policies.py b/tests/unit/resources/catalog/test_pricing_policies.py index 628391f2..9aabafb8 100644 --- a/tests/unit/resources/catalog/test_pricing_policies.py +++ b/tests/unit/resources/catalog/test_pricing_policies.py @@ -2,9 +2,11 @@ import pytest import respx +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.pricing_policies import ( AsyncPricingPoliciesService, PricingPoliciesService, + PricingPolicy, ) from mpt_api_client.resources.catalog.pricing_policy_attachments import ( AsyncPricingPolicyAttachmentsService, @@ -22,6 +24,24 @@ def async_pricing_policies_service(async_http_client): return AsyncPricingPoliciesService(http_client=async_http_client) +@pytest.fixture +def pricing_policy_data(): + return { + "id": "PRP-001", + "name": "My Policy", + "notes": "Some notes", + "status": "Active", + "markup": 25.0, + "margin": 20.0, + "externalIds": {"vendor": "ext-001"}, + "client": {"id": "ACC-001", "name": "Client"}, + "eligibility": {"status": "Eligible"}, + "products": [{"id": "PRD-001", "name": "My Product"}], + "statistics": {"items": 5}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + def test_activate(pricing_policies_service): pricing_policy_expected = { "id": "PRP-0000-0001", @@ -160,3 +180,28 @@ def test_async_mixins_present(async_pricing_policies_service, method): result = hasattr(async_pricing_policies_service, method) assert result is True + + +def test_pricing_policy_primitive_fields(pricing_policy_data): + result = PricingPolicy(pricing_policy_data) + + assert result.to_dict() == pricing_policy_data + + +def test_pricing_policy_nested_models(pricing_policy_data): + result = PricingPolicy(pricing_policy_data) + + assert isinstance(result.external_ids, BaseModel) + assert isinstance(result.client, BaseModel) + assert isinstance(result.eligibility, BaseModel) + assert isinstance(result.statistics, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_pricing_policy_optional_fields_absent(): + result = PricingPolicy({"id": "PRP-001"}) + + assert result.id == "PRP-001" + assert not hasattr(result, "name") + assert not hasattr(result, "status") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_pricing_policy_attachments.py b/tests/unit/resources/catalog/test_pricing_policy_attachments.py index 061918a4..ec923977 100644 --- a/tests/unit/resources/catalog/test_pricing_policy_attachments.py +++ b/tests/unit/resources/catalog/test_pricing_policy_attachments.py @@ -1,7 +1,9 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.pricing_policy_attachments import ( AsyncPricingPolicyAttachmentsService, + PricingPolicyAttachment, PricingPolicyAttachmentsService, ) @@ -22,6 +24,21 @@ def async_pricing_policy_attachments_service( ) +@pytest.fixture +def pricing_policy_attachment_data(): + return { + "id": "ATT-001", + "name": "Terms of Service", + "type": "Document", + "size": 1024, + "description": "Attachment description", + "fileName": "terms.pdf", + "contentType": "application/pdf", + "status": "Active", + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + def test_endpoint(pricing_policy_attachments_service) -> None: result = ( pricing_policy_attachments_service.path @@ -52,3 +69,24 @@ def test_async_methods_present(async_pricing_policy_attachments_service, method: result = hasattr(async_pricing_policy_attachments_service, method) assert result is True + + +def test_pricing_policy_attach_primitives(pricing_policy_attachment_data): + result = PricingPolicyAttachment(pricing_policy_attachment_data) + + assert result.to_dict() == pricing_policy_attachment_data + + +def test_pricing_policy_attachment_nested_models(pricing_policy_attachment_data): + result = PricingPolicyAttachment(pricing_policy_attachment_data) + + assert isinstance(result.audit, BaseModel) + + +def test_pricing_policy_attachment_absent(): + result = PricingPolicyAttachment({"id": "ATT-001"}) + + assert result.id == "ATT-001" + assert not hasattr(result, "name") + assert not hasattr(result, "status") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_product_term_variants.py b/tests/unit/resources/catalog/test_product_term_variants.py index a028c899..940ff4f6 100644 --- a/tests/unit/resources/catalog/test_product_term_variants.py +++ b/tests/unit/resources/catalog/test_product_term_variants.py @@ -2,8 +2,10 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.product_term_variants import ( AsyncTermVariantService, + TermVariant, TermVariantService, ) @@ -23,6 +25,25 @@ def async_term_variant_service(async_http_client: Any) -> AsyncTermVariantServic ) +@pytest.fixture +def term_variant_data(): + return { + "id": "TRV-001", + "type": "PDF", + "assetUrl": "https://example.com/file.pdf", + "languageCode": "en-US", + "name": "English Terms", + "description": "English language terms", + "status": "Active", + "filename": "terms.pdf", + "size": 2048, + "contentType": "application/pdf", + "termsAndConditions": {"id": "TRM-001", "name": "Terms of Service"}, + "fileId": "FILE-001", + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + def test_endpoint(term_variant_service: TermVariantService) -> None: result = ( term_variant_service.path == "/public/v1/catalog/products/PRD-001/terms/TRM-001/variants" @@ -60,3 +81,25 @@ def test_async_methods_present( result = hasattr(async_term_variant_service, method) assert result is True + + +def test_term_variant_primitive_fields(term_variant_data: dict) -> None: + result = TermVariant(term_variant_data) + + assert result.to_dict() == term_variant_data + + +def test_term_variant_nested_base_models(term_variant_data: dict) -> None: + result = TermVariant(term_variant_data) + + assert isinstance(result.terms_and_conditions, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_term_variant_optional_fields_absent() -> None: + result = TermVariant({"id": "TRV-001"}) + + assert result.id == "TRV-001" + assert not hasattr(result, "name") + assert not hasattr(result, "status") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_product_terms.py b/tests/unit/resources/catalog/test_product_terms.py index f6384109..941d8173 100644 --- a/tests/unit/resources/catalog/test_product_terms.py +++ b/tests/unit/resources/catalog/test_product_terms.py @@ -2,12 +2,14 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.product_term_variants import ( AsyncTermVariantService, TermVariantService, ) from mpt_api_client.resources.catalog.product_terms import ( AsyncTermService, + Term, TermService, ) @@ -24,6 +26,19 @@ def async_term_service(async_http_client: Any) -> AsyncTermService: ) +@pytest.fixture +def term_data(): + return { + "id": "TRM-001", + "name": "Terms of Service", + "description": "Standard terms", + "displayOrder": 1, + "status": "Active", + "product": {"id": "PRD-001", "name": "My Product"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + def test_endpoint(term_service: TermService) -> None: result = term_service.path == "/public/v1/catalog/products/PRD-001/terms" @@ -68,3 +83,25 @@ def test_async_variants_property(async_term_service: AsyncTermService) -> None: assert isinstance(result, AsyncTermVariantService) assert result.http_client == async_term_service.http_client assert result.endpoint_params == {"product_id": "PRD-001", "term_id": "TCS-001"} + + +def test_term_primitive_fields(term_data: dict) -> None: + result = Term(term_data) + + assert result.to_dict() == term_data + + +def test_term_nested_fields_are_base_models(term_data: dict) -> None: + result = Term(term_data) + + assert isinstance(result.product, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_term_optional_fields_absent() -> None: + result = Term({"id": "TRM-001"}) + + assert result.id == "TRM-001" + assert not hasattr(result, "name") + assert not hasattr(result, "status") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_products.py b/tests/unit/resources/catalog/test_products.py index 6ba3e933..4f5c8025 100644 --- a/tests/unit/resources/catalog/test_products.py +++ b/tests/unit/resources/catalog/test_products.py @@ -2,11 +2,12 @@ import pytest import respx +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.product_terms import ( AsyncTermService, TermService, ) -from mpt_api_client.resources.catalog.products import AsyncProductsService, ProductsService +from mpt_api_client.resources.catalog.products import AsyncProductsService, Product, ProductsService from mpt_api_client.resources.catalog.products_documents import ( AsyncDocumentService, DocumentService, @@ -227,6 +228,49 @@ def test_sync_product_update(products_service, tmp_path): assert result.to_dict() == expected_response +@pytest.fixture +def product_data(): + return { + "id": "PRD-001", + "name": "My Product", + "shortDescription": "Short desc", + "longDescription": "Long description of the product", + "externalIds": {"vendor": "ext-001"}, + "website": "https://example.com", + "icon": "https://example.com/icon.png", + "status": "Active", + "vendor": {"id": "ACC-001", "name": "Vendor"}, + "settings": {"allowCustomization": True}, + "statistics": {"listings": 3}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +def test_product_primitive_fields(product_data): + result = Product(product_data) + + assert result.to_dict() == product_data + + +def test_product_nested_fields_are_base_models(product_data): + result = Product(product_data) + + assert isinstance(result.external_ids, BaseModel) + assert isinstance(result.vendor, BaseModel) + assert isinstance(result.settings, BaseModel) + assert isinstance(result.statistics, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_product_optional_fields_absent(): + result = Product({"id": "PRD-001"}) + + assert result.id == "PRD-001" + assert not hasattr(result, "name") + assert not hasattr(result, "status") + assert not hasattr(result, "audit") + + async def test_async_product_update(async_products_service, tmp_path): product_id = "PRD-456" update_data = {"name": "Async Updated Product", "category": "Gadgets"} diff --git a/tests/unit/resources/catalog/test_products_documents.py b/tests/unit/resources/catalog/test_products_documents.py index 8d65275c..af22ccfa 100644 --- a/tests/unit/resources/catalog/test_products_documents.py +++ b/tests/unit/resources/catalog/test_products_documents.py @@ -1,7 +1,9 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.products_documents import ( AsyncDocumentService, + Document, DocumentService, ) @@ -18,6 +20,24 @@ def async_document_service(async_http_client) -> AsyncDocumentService: ) +@pytest.fixture +def document_data(): + return { + "id": "DOC-001", + "name": "User Guide", + "type": "UserGuide", + "description": "Product user guide", + "status": "Active", + "filename": "guide.pdf", + "size": 4096, + "contentType": "application/pdf", + "url": "https://example.com/guide.pdf", + "language": "en", + "product": {"id": "PRD-001", "name": "My Product"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + def test_endpoint(document_service) -> None: result = document_service.path == "/public/v1/catalog/products/PRD-001/documents" @@ -48,3 +68,25 @@ def test_async_methods_present(async_document_service, method: str) -> None: result = hasattr(async_document_service, method) assert result is True + + +def test_document_primitive_fields(document_data): + result = Document(document_data) + + assert result.to_dict() == document_data + + +def test_document_nested_fields_are_base_models(document_data): + result = Document(document_data) + + assert isinstance(result.product, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_document_optional_fields_absent(): + result = Document({"id": "DOC-001"}) + + assert result.id == "DOC-001" + assert not hasattr(result, "name") + assert not hasattr(result, "status") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_products_item_groups.py b/tests/unit/resources/catalog/test_products_item_groups.py index 23fe5887..a0eb6f82 100644 --- a/tests/unit/resources/catalog/test_products_item_groups.py +++ b/tests/unit/resources/catalog/test_products_item_groups.py @@ -1,7 +1,9 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.products_item_groups import ( AsyncItemGroupsService, + ItemGroup, ItemGroupsService, ) @@ -18,6 +20,23 @@ def async_item_groups_service(async_http_client): ) +@pytest.fixture +def item_group_data(): + return { + "id": "GRP-001", + "name": "Standard Options", + "label": "Standard", + "description": "Standard option group", + "displayOrder": 1, + "default": True, + "multiple": False, + "required": True, + "itemCount": 5, + "product": {"id": "PRD-001", "name": "My Product"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + def test_endpoint(item_groups_service): result = item_groups_service.path == "/public/v1/catalog/products/PRD-001/item-groups" @@ -42,3 +61,25 @@ def test_async_methods_present(async_item_groups_service, method): result = hasattr(async_item_groups_service, method) assert result is True + + +def test_item_group_primitive_fields(item_group_data): + result = ItemGroup(item_group_data) + + assert result.to_dict() == item_group_data + + +def test_item_group_nested_fields_are_base_models(item_group_data): + result = ItemGroup(item_group_data) + + assert isinstance(result.product, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_item_group_optional_fields_absent(): + result = ItemGroup({"id": "GRP-001"}) + + assert result.id == "GRP-001" + assert not hasattr(result, "name") + assert not hasattr(result, "default") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_products_media.py b/tests/unit/resources/catalog/test_products_media.py index f4b023c7..bd789af9 100644 --- a/tests/unit/resources/catalog/test_products_media.py +++ b/tests/unit/resources/catalog/test_products_media.py @@ -1,7 +1,9 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.products_media import ( AsyncMediaService, + Media, MediaService, ) @@ -18,6 +20,24 @@ def async_media_service(async_http_client) -> AsyncMediaService: ) +@pytest.fixture +def media_data(): + return { + "id": "MED-001", + "name": "Product Screenshot", + "type": "Image", + "description": "Main product screenshot", + "status": "Active", + "filename": "screenshot.png", + "size": 512000, + "contentType": "image/png", + "displayOrder": 1, + "url": "https://example.com/screenshot.png", + "product": {"id": "PRD-001", "name": "My Product"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + def test_endpoint(media_service) -> None: result = media_service.path == "/public/v1/catalog/products/PRD-001/media" @@ -48,3 +68,25 @@ def test_async_methods_present(async_media_service, method: str) -> None: result = hasattr(async_media_service, method) assert result is True + + +def test_media_primitive_fields(media_data): + result = Media(media_data) + + assert result.to_dict() == media_data + + +def test_media_nested_fields_are_base_models(media_data): + result = Media(media_data) + + assert isinstance(result.product, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_media_optional_fields_absent(): + result = Media({"id": "MED-001"}) + + assert result.id == "MED-001" + assert not hasattr(result, "name") + assert not hasattr(result, "status") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_products_templates.py b/tests/unit/resources/catalog/test_products_templates.py index 9c2443f8..7a643a1c 100644 --- a/tests/unit/resources/catalog/test_products_templates.py +++ b/tests/unit/resources/catalog/test_products_templates.py @@ -1,7 +1,9 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.products_templates import ( AsyncTemplatesService, + Template, TemplatesService, ) @@ -18,6 +20,19 @@ def async_templates_service(async_http_client): ) +@pytest.fixture +def template_data(): + return { + "id": "TPL-001", + "name": "Order Confirmation", + "content": "

Your order has been confirmed.

", + "type": "Email", + "default": True, + "product": {"id": "PRD-001", "name": "My Product"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + def test_endpoint(templates_service): result = templates_service.path == "/public/v1/catalog/products/PRD-001/templates" @@ -42,3 +57,25 @@ def test_async_methods_present(async_templates_service, method): result = hasattr(async_templates_service, method) assert result is True + + +def test_template_primitive_fields(template_data): + result = Template(template_data) + + assert result.to_dict() == template_data + + +def test_template_nested_fields_are_base_models(template_data): + result = Template(template_data) + + assert isinstance(result.product, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_template_optional_fields_absent(): + result = Template({"id": "TPL-001"}) + + assert result.id == "TPL-001" + assert not hasattr(result, "name") + assert not hasattr(result, "default") + assert not hasattr(result, "audit") diff --git a/tests/unit/resources/catalog/test_units_of_measure.py b/tests/unit/resources/catalog/test_units_of_measure.py index d471455f..d85502a7 100644 --- a/tests/unit/resources/catalog/test_units_of_measure.py +++ b/tests/unit/resources/catalog/test_units_of_measure.py @@ -1,7 +1,9 @@ import pytest +from mpt_api_client.models.model import BaseModel from mpt_api_client.resources.catalog.units_of_measure import ( AsyncUnitsOfMeasureService, + UnitOfMeasure, UnitsOfMeasureService, ) @@ -16,6 +18,17 @@ def async_units_of_measure_service(async_http_client): return AsyncUnitsOfMeasureService(http_client=async_http_client) +@pytest.fixture +def unit_of_measure_data(): + return { + "id": "UOM-001", + "description": "A single unit", + "name": "Each", + "statistics": {"items": 100}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + @pytest.mark.parametrize("method", ["get", "create", "update", "delete", "iterate"]) def test_mixins_present(units_of_measure_service, method): result = hasattr(units_of_measure_service, method) @@ -28,3 +41,25 @@ def test_async_mixins_present(async_units_of_measure_service, method): result = hasattr(async_units_of_measure_service, method) assert result is True + + +def test_unit_of_measure_primitive_fields(unit_of_measure_data): + result = UnitOfMeasure(unit_of_measure_data) + + assert result.to_dict() == unit_of_measure_data + + +def test_unit_of_measure_nested_models(unit_of_measure_data): + result = UnitOfMeasure(unit_of_measure_data) + + assert isinstance(result.statistics, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_unit_of_measure_optional_fields_absent(): + result = UnitOfMeasure({"id": "UOM-001"}) + + assert result.id == "UOM-001" + assert not hasattr(result, "name") + assert not hasattr(result, "description") + assert not hasattr(result, "audit") From 2c857ddbee90ca6d8d4bb2321fcaeb22214a5f60 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Fri, 13 Mar 2026 18:03:32 +0000 Subject: [PATCH 2/2] MPT-17888: fix bidirectional field mapping for consecutive uppercase letters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add _FIELD_NAME_MAPPINGS (MappingProxyType) in model.py for API fields that contain two or more consecutive uppercase letters (PPx1, SPxM, unitLP, totalGT, etc.). The generic camelCase<->snake_case regex cannot round-trip these correctly, so an explicit lookup table is checked first in both to_snake_case and to_camel_case. Affected fields from OpenAPI spec: - PP*/SP*/LP* price columns (PPx1→ppx1, SPxM→spxm, LPxY→lpxy, ...) - unit+acronym fields (unitLP→unit_lp, unitPP→unit_pp, unitSP→unit_sp) - total+acronym fields (totalGT→total_gt, totalPP→total_pp, ...) PriceListItem model annotations updated to use the corrected names (ppx1, spxm, lpx1, etc. instead of p_px1, s_px_m, l_px1, ...). to_dict() round-trip now works correctly for all price columns. Tests: - Merged price_list_item price fixture into main fixture for full round-trip test coverage - Added parametrized tests for to_snake_case and to_camel_case with consecutive-uppercase fields in tests/unit/models/test_model.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 2 + mpt_api_client/models/model.py | 50 ++++++++++++++++++- .../resources/catalog/price_list_items.py | 36 ++++++------- tests/unit/models/test_model.py | 37 +++++++++++++- .../catalog/test_price_list_items.py | 21 +++----- 5 files changed, 111 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 413c2dab..0e321204 100644 --- a/.gitignore +++ b/.gitignore @@ -167,6 +167,8 @@ cython_debug/ .ruff_cache .idea .openapi/ +openapi/openapi-dev.json +.github/copilot-instructions.md # Makefile make/local.mk diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index d3736b58..0da33232 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -1,6 +1,7 @@ import re from collections import UserList from collections.abc import Iterable +from types import MappingProxyType from typing import Any, Self, get_args, get_origin, override from mpt_api_client.http.types import Response @@ -12,9 +13,47 @@ _SNAKE_CASE_BOUNDARY = re.compile(r"([a-z0-9])([A-Z])") _SNAKE_CASE_ACRONYM = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z0-9])") +# Explicit bidirectional mappings for API field names that contain two or more consecutive +# uppercase letters (e.g. PPx1, unitLP). The generic regex cannot round-trip these correctly, +# so we maintain an explicit lookup table that is checked before the regex is applied. +_FIELD_NAME_MAPPINGS: MappingProxyType[str, str] = MappingProxyType({ + # PP* price columns + "PPx1": "ppx1", + "PPxM": "ppxm", + "PPxY": "ppxy", + # SP* price columns + "SPx1": "spx1", + "SPxM": "spxm", + "SPxY": "spxy", + # LP* price columns + "LPx1": "lpx1", + "LPxM": "lpxm", + "LPxY": "lpxy", + # unit + 2-letter acronym suffix + "unitLP": "unit_lp", + "unitPP": "unit_pp", + "unitSP": "unit_sp", + # total + 2-letter acronym suffix + "totalGT": "total_gt", + "totalPP": "total_pp", + "totalSP": "total_sp", + "totalST": "total_st", +}) + +_FIELD_NAME_MAPPINGS_REVERSE: MappingProxyType[str, str] = MappingProxyType({ + snake: camel for camel, snake in _FIELD_NAME_MAPPINGS.items() +}) + def to_snake_case(key: str) -> str: - """Converts a camelCase string to snake_case.""" + """Converts a camelCase string to snake_case. + + Explicit mappings in ``_FIELD_NAME_MAPPINGS`` take priority over the generic + regex for fields that contain two or more consecutive uppercase letters. + """ + mapped = _FIELD_NAME_MAPPINGS.get(key) + if mapped is not None: + return mapped if "_" in key and key.islower(): return key # Common pattern for PascalCase/camelCase conversion @@ -24,7 +63,14 @@ def to_snake_case(key: str) -> str: def to_camel_case(key: str) -> str: - """Converts a snake_case string to camelCase.""" + """Converts a snake_case string to camelCase. + + Explicit mappings in ``_FIELD_NAME_MAPPINGS_REVERSE`` take priority over the + generic logic for fields that contain two or more consecutive uppercase letters. + """ + mapped = _FIELD_NAME_MAPPINGS_REVERSE.get(key) + if mapped is not None: + return mapped parts = key.split("_") return parts[0] + "".join(x.title() for x in parts[1:]) # noqa: WPS111 WPS221 diff --git a/mpt_api_client/resources/catalog/price_list_items.py b/mpt_api_client/resources/catalog/price_list_items.py index 479f269a..11779ce4 100644 --- a/mpt_api_client/resources/catalog/price_list_items.py +++ b/mpt_api_client/resources/catalog/price_list_items.py @@ -23,15 +23,15 @@ class PriceListItem(Model): markup: Markup percentage. margin: Margin percentage. unit_sp: Unit sell price. - p_px1: Purchase price for 1-year term. - p_px_m: Purchase price for monthly term. - p_px_y: Purchase price for yearly term. - s_px1: Sell price for 1-year term. - s_px_m: Sell price for monthly term. - s_px_y: Sell price for yearly term. - l_px1: List price for 1-year term. - l_px_m: List price for monthly term. - l_px_y: List price for yearly term. + ppx1: Purchase price for 1-year term. + ppxm: Purchase price for monthly term. + ppxy: Purchase price for yearly term. + spx1: Sell price for 1-year term. + spxm: Sell price for monthly term. + spxy: Sell price for yearly term. + lpx1: List price for 1-year term. + lpxm: List price for monthly term. + lpxy: List price for yearly term. price_list: Reference to the parent price list. item: Reference to the associated item. audit: Audit information (created, updated events). @@ -45,15 +45,15 @@ class PriceListItem(Model): markup: float | None margin: float | None unit_sp: float | None - p_px1: float | None - p_px_m: float | None - p_px_y: float | None - s_px1: float | None - s_px_m: float | None - s_px_y: float | None - l_px1: float | None - l_px_m: float | None - l_px_y: float | None + ppx1: float | None + ppxm: float | None + ppxy: float | None + spx1: float | None + spxm: float | None + spxy: float | None + lpx1: float | None + lpxm: float | None + lpxy: float | None price_list: BaseModel | None item: BaseModel | None audit: BaseModel | None diff --git a/tests/unit/models/test_model.py b/tests/unit/models/test_model.py index 9082303a..8285efb6 100644 --- a/tests/unit/models/test_model.py +++ b/tests/unit/models/test_model.py @@ -2,7 +2,12 @@ from httpx import Response from mpt_api_client.models import Meta, Model -from mpt_api_client.models.model import BaseModel, ModelList, to_snake_case # noqa: WPS347 +from mpt_api_client.models.model import ( # noqa: WPS347 + BaseModel, + ModelList, + to_camel_case, + to_snake_case, +) class AgreementDummy(Model): # noqa: WPS431 @@ -326,3 +331,33 @@ def test_process_value_scalar_list_elements(): assert isinstance(container.tags, ModelList) assert list(container.tags) == ["a", "b", "c"] + + +@pytest.mark.parametrize( + ("camel", "snake"), + [ + ("PPx1", "ppx1"), + ("SPxM", "spxm"), + ("unitLP", "unit_lp"), + ("totalGT", "total_gt"), + ], +) +def test_to_snake_case_consecutive_uppercase(camel, snake): + result = to_snake_case(camel) # act + + assert result == snake + + +@pytest.mark.parametrize( + ("snake", "camel"), + [ + ("ppx1", "PPx1"), + ("spxm", "SPxM"), + ("unit_lp", "unitLP"), + ("total_gt", "totalGT"), + ], +) +def test_to_camel_case_consecutive_uppercase(snake, camel): + result = to_camel_case(snake) # act + + assert result == camel diff --git a/tests/unit/resources/catalog/test_price_list_items.py b/tests/unit/resources/catalog/test_price_list_items.py index 79f3d20e..80032ac3 100644 --- a/tests/unit/resources/catalog/test_price_list_items.py +++ b/tests/unit/resources/catalog/test_price_list_items.py @@ -29,16 +29,6 @@ def price_list_item_data(): "status": "Active", "description": "Item description", "reasonForChange": "Price update", - "priceList": {"id": "PRC-001", "currency": "USD"}, - "item": {"id": "ITM-001", "name": "My Item"}, - "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, - } - - -@pytest.fixture -def price_list_item_price_data(): - return { - "id": "PLI-002", "unitLP": 100.0, "unitPP": 80.0, "markup": 25.0, @@ -53,6 +43,9 @@ def price_list_item_price_data(): "LPx1": 100.0, "LPxM": 9.0, "LPxY": 108.0, + "priceList": {"id": "PRC-001", "currency": "USD"}, + "item": {"id": "ITM-001", "name": "My Item"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, } @@ -92,14 +85,14 @@ def test_price_list_item_primitive_fields(price_list_item_data): assert result.to_dict() == price_list_item_data -def test_price_list_item_price_fields(price_list_item_price_data): - result = PriceListItem(price_list_item_price_data) +def test_price_list_item_price_fields(price_list_item_data): + result = PriceListItem(price_list_item_data) assert result.unit_lp == pytest.approx(100.0) assert result.unit_pp == pytest.approx(80.0) assert result.unit_sp == pytest.approx(90.0) - assert result.markup == pytest.approx(25.0) - assert result.margin == pytest.approx(20.0) + assert result.spxm == pytest.approx(8.0) + assert result.lpx1 == pytest.approx(100.0) def test_price_list_item_nested_models(price_list_item_data):