diff --git a/Modules/Sources/Networking/Model/Product/Product.swift b/Modules/Sources/Networking/Model/Product/Product.swift index c459d11e0eb..d65577c4195 100644 --- a/Modules/Sources/Networking/Model/Product/Product.swift +++ b/Modules/Sources/Networking/Model/Product/Product.swift @@ -457,7 +457,7 @@ public struct Product: Codable, GeneratedCopiable, Equatable, GeneratedFakeable // `true` value.lowercased() == Values.manageStockParent ? true : false }) - ]) ?? false + ]) ?? false // Even though WooCommerce Core returns Int or null values, // some plugins alter the field value from Int to Decimal or String. @@ -535,15 +535,17 @@ public struct Product: Codable, GeneratedCopiable, Equatable, GeneratedFakeable let menuOrder = try container.decode(Int.self, forKey: .menuOrder) // Filter out metadata if the key is prefixed with an underscore (internal meta keys) - let customFields = (try? container.decode([MetaData].self, forKey: .metadata).filter({ !$0.key.hasPrefix("_")})) ?? [] + // Support both array format and object keyed by index strings + let allMetaData = [MetaData].decodeFlexibly(from: container, forKey: .metadata) + let customFields = allMetaData.filter { !$0.key.hasPrefix("_") } // In some isolated cases, it appears to be some malformed meta-data that causes this line to throw hence the whole product decoding to throw. // Since add-ons are optional, `try?` will be used to prevent the whole decoding to stop. // https://github.com/woocommerce/woocommerce-ios/issues/4205 let addOns = (try? container.decodeIfPresent(ProductAddOnEnvelope.self, forKey: .metadata)?.revolve()) ?? [] - let metaDataExtractor = try? container.decodeIfPresent(ProductMetadataExtractor.self, forKey: .metadata) - let isSampleItem = (metaDataExtractor?.extractStringValue(forKey: MetadataKeys.headStartPost) == Values.headStartValue) + let metaDataExtractor = ProductMetadataExtractor(metadata: allMetaData) + let isSampleItem = (metaDataExtractor.extractStringValue(forKey: MetadataKeys.headStartPost) == Values.headStartValue) // Product Bundle properties // Uses failsafe decoding because non-bundle product types can return unexpected value types. @@ -561,7 +563,7 @@ public struct Product: Codable, GeneratedCopiable, Equatable, GeneratedFakeable let compositeComponents = try container.decodeIfPresent([ProductCompositeComponent].self, forKey: .compositeComponents) ?? [] // Subscription properties - let subscription = try? metaDataExtractor?.extractProductSubscription() + let subscription = try? metaDataExtractor.extractProductSubscription() // Min/Max Quantities properties let minAllowedQuantity = container.failsafeDecodeIfPresent(stringForKey: .minAllowedQuantity) diff --git a/Modules/Sources/Networking/Model/Product/ProductMetadataExtractor.swift b/Modules/Sources/Networking/Model/Product/ProductMetadataExtractor.swift index b1c77e9a839..d2cec225b93 100644 --- a/Modules/Sources/Networking/Model/Product/ProductMetadataExtractor.swift +++ b/Modules/Sources/Networking/Model/Product/ProductMetadataExtractor.swift @@ -1,5 +1,6 @@ import Foundation import WordPressShared +import NetworkingCore /// Helper to extract specific data from inside `Product` metadata. /// Sample Json: @@ -21,25 +22,23 @@ import WordPressShared /// } /// ] /// -internal struct ProductMetadataExtractor: Decodable { +struct ProductMetadataExtractor { - private typealias DecodableDictionary = [String: AnyDecodable] private typealias AnyDictionary = [String: Any?] /// Internal metadata representation /// - private let metadata: [DecodableDictionary] + private let metadata: [MetaData] - /// Decode main metadata array as an untyped dictionary. + /// Initialize with already-decoded metadata array /// - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - self.metadata = try container.decode([DecodableDictionary].self) + init(metadata: [MetaData]) { + self.metadata = metadata } /// Searches product metadata for subscription data and converts it to a `ProductSubscription` if possible. /// - internal func extractProductSubscription() throws -> ProductSubscription? { + func extractProductSubscription() throws -> ProductSubscription? { let subscriptionMetadata = filterMetadata(with: Constants.subscriptionPrefix) guard !subscriptionMetadata.isEmpty else { @@ -54,7 +53,7 @@ internal struct ProductMetadataExtractor: Decodable { /// Extracts a `String` metadata value for the provided key. /// - internal func extractStringValue(forKey key: String) -> String? { + func extractStringValue(forKey key: String) -> String? { let metaData = filterMetadata(with: key) let keyValueMetadata = getKeyValueDictionary(from: metaData) return keyValueMetadata.valueAsString(forKey: key) @@ -62,22 +61,25 @@ internal struct ProductMetadataExtractor: Decodable { /// Filters product metadata using the provided prefix. /// - private func filterMetadata(with prefix: String) -> [DecodableDictionary] { - metadata.filter { object in - let objectKey = object["key"]?.value as? String ?? "" - return objectKey.hasPrefix(prefix) - } + private func filterMetadata(with prefix: String) -> [MetaData] { + metadata.filter { $0.key.hasPrefix(prefix) } } /// Parses provided metadata to return a dictionary with each metadata object's key and value. /// - private func getKeyValueDictionary(from metadata: [DecodableDictionary]) -> AnyDictionary { - metadata.reduce(AnyDictionary()) { (dict, object) in - var newDict = dict - let objectKey = object["key"]?.value as? String ?? "" - let objectValue = object["value"]?.value - newDict.updateValue(objectValue, forKey: objectKey) - return newDict + private func getKeyValueDictionary(from metadata: [MetaData]) -> AnyDictionary { + metadata.reduce(into: AnyDictionary()) { dict, object in + // For JSON values, decode them to get the actual object + if object.value.isJson { + if let data = object.value.stringValue.data(using: .utf8), + let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) { + dict[object.key] = jsonObject + } else { + dict[object.key] = object.value.stringValue + } + } else { + dict[object.key] = object.value.stringValue + } } } diff --git a/Modules/Sources/Networking/Model/Product/ProductVariation.swift b/Modules/Sources/Networking/Model/Product/ProductVariation.swift index b64be6863ad..46356c948d7 100644 --- a/Modules/Sources/Networking/Model/Product/ProductVariation.swift +++ b/Modules/Sources/Networking/Model/Product/ProductVariation.swift @@ -279,7 +279,7 @@ public struct ProductVariation: Codable, GeneratedCopiable, Equatable, Generated } return false }) - ]) ?? false + ]) ?? false // Even though WooCommerce Core returns Int or null values, // some plugins alter the field value from Int to Decimal or String. @@ -309,7 +309,9 @@ public struct ProductVariation: Codable, GeneratedCopiable, Equatable, Generated let menuOrder = try container.decode(Int64.self, forKey: .menuOrder) // Subscription settings for subscription variations - let subscription = try? container.decodeIfPresent(ProductMetadataExtractor.self, forKey: .metadata)?.extractProductSubscription() + let allMetaData = [MetaData].decodeFlexibly(from: container, forKey: .metadata) + let metaDataExtractor = ProductMetadataExtractor(metadata: allMetaData) + let subscription = try? metaDataExtractor.extractProductSubscription() // Min/Max Quantities properties let minAllowedQuantity = container.failsafeDecodeIfPresent(stringForKey: .minAllowedQuantity) diff --git a/Modules/Sources/NetworkingCore/Model/MetaDataArray+FlexibleDecoding.swift b/Modules/Sources/NetworkingCore/Model/MetaDataArray+FlexibleDecoding.swift new file mode 100644 index 00000000000..21e85eb93c0 --- /dev/null +++ b/Modules/Sources/NetworkingCore/Model/MetaDataArray+FlexibleDecoding.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Extension to support flexible decoding of MetaData arrays +/// Handles both standard array format and object format keyed by index strings +extension Array where Element == MetaData { + + /// Custom decoding from container that supports both array and dictionary formats + public static func decodeFlexibly(from container: KeyedDecodingContainer, + forKey key: KeyedDecodingContainer.Key) -> [MetaData] { + // Try to decode as array first (standard format) + if let metaDataArray = try? container.decode([MetaData].self, forKey: key) { + return metaDataArray + } + + // Try to decode as object keyed by index strings + if let metaDataDict = try? container.decode([String: MetaData].self, forKey: key) { + return Array(metaDataDict.values) + } + + // Fallback to empty array + DDLogWarn("⚠️ Could not decode metadata as either an array or object keyed by index strings. Falling back to empty array.") + return [] + } +} diff --git a/Modules/Sources/NetworkingCore/Model/Order.swift b/Modules/Sources/NetworkingCore/Model/Order.swift index e88d265c792..946fa1bc246 100644 --- a/Modules/Sources/NetworkingCore/Model/Order.swift +++ b/Modules/Sources/NetworkingCore/Model/Order.swift @@ -201,7 +201,10 @@ public struct Order: Decodable, Sendable, GeneratedCopiable, GeneratedFakeable { // "payment_url" is only available on stores with version >= 6.4 let paymentURL = try container.decodeIfPresent(URL.self, forKey: .paymentURL) - let allOrderMetaData = try? container.decode([MetaData].self, forKey: .metadata) + let allOrderMetaData: [MetaData]? = { + let metadata = [MetaData].decodeFlexibly(from: container, forKey: .metadata) + return metadata.isEmpty ? nil : metadata + }() var chargeID: String? = nil chargeID = allOrderMetaData?.first(where: { $0.key == "_charge_id" })?.value.stringValue diff --git a/Modules/Tests/NetworkingTests/Mapper/MetaDataMapperTests.swift b/Modules/Tests/NetworkingTests/Mapper/MetaDataMapperTests.swift index 4139579513b..9966a797545 100644 --- a/Modules/Tests/NetworkingTests/Mapper/MetaDataMapperTests.swift +++ b/Modules/Tests/NetworkingTests/Mapper/MetaDataMapperTests.swift @@ -49,6 +49,136 @@ final class MetaDataMapperTests: XCTestCase { XCTAssertEqual(metadata[5], MetaData(metadataID: 6, key: "number_field", value: "42")) XCTAssertEqual(metadata[6], MetaData(metadataID: 7, key: "empty_field", value: "")) } + + /// Tests that flexible metadata decoding works with array format in a realistic context + /// + func test_flexible_metadata_decoding_array_format() throws { + // Given - JSON with metadata as array in a product-like structure + let jsonString = """ + { + "id": 123, + "name": "Test Product", + "meta_data": [ + { + "id": 1001, + "key": "custom_field_1", + "value": "value1" + }, + { + "id": 1002, + "key": "_internal_field", + "value": "internal_value" + }, + { + "id": 1003, + "key": "custom_field_2", + "value": "value2" + } + ] + } + """ + + struct TestProduct: Decodable { + let id: Int + let name: String + let metadata: [MetaData] + + private enum CodingKeys: String, CodingKey { + case id, name + case metadata = "meta_data" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(Int.self, forKey: .id) + self.name = try container.decode(String.self, forKey: .name) + + // Flexible decoding logic using helper + self.metadata = [MetaData].decodeFlexibly(from: container, forKey: .metadata) + } + } + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + // When + let product = try decoder.decode(TestProduct.self, from: data) + + // Then + XCTAssertEqual(product.metadata.count, 3) // All fields should be present + XCTAssertEqual(product.metadata[0].metadataID, 1001) + XCTAssertEqual(product.metadata[0].key, "custom_field_1") + XCTAssertEqual(product.metadata[0].value.stringValue, "value1") + XCTAssertEqual(product.metadata[1].metadataID, 1002) + XCTAssertEqual(product.metadata[1].key, "_internal_field") + XCTAssertEqual(product.metadata[1].value.stringValue, "internal_value") + XCTAssertEqual(product.metadata[2].metadataID, 1003) + XCTAssertEqual(product.metadata[2].key, "custom_field_2") + XCTAssertEqual(product.metadata[2].value.stringValue, "value2") + } + + /// Tests that flexible metadata decoding works with dictionary format in a realistic context + /// + func test_flexible_metadata_decoding_dictionary_format() throws { + // Given - JSON with metadata as object keyed by index strings in a product-like structure + let jsonString = """ + { + "id": 456, + "name": "Test Product 2", + "meta_data": { + "0": { + "id": 2001, + "key": "dict_field_1", + "value": "dict_value1" + }, + "1": { + "id": 2002, + "key": "_internal_dict_field", + "value": "internal_dict_value" + }, + "2": { + "id": 2003, + "key": "dict_field_2", + "value": "dict_value2" + } + } + } + """ + + struct TestProduct: Decodable { + let id: Int + let name: String + let metadata: [MetaData] + + private enum CodingKeys: String, CodingKey { + case id, name + case metadata = "meta_data" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(Int.self, forKey: .id) + self.name = try container.decode(String.self, forKey: .name) + + // Flexible decoding logic using helper + self.metadata = [MetaData].decodeFlexibly(from: container, forKey: .metadata) + } + } + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + // When + let product = try decoder.decode(TestProduct.self, from: data) + + // Then + XCTAssertEqual(product.metadata.count, 3) // All fields should be present + let fieldNames = Set(product.metadata.map { $0.key }) + XCTAssertTrue(fieldNames.contains("dict_field_1")) + XCTAssertTrue(fieldNames.contains("dict_field_2")) + XCTAssertTrue(fieldNames.contains("_internal_dict_field")) + } + } // MARK: - Test Helpers diff --git a/Modules/Tests/NetworkingTests/Mapper/OrderMapperTests.swift b/Modules/Tests/NetworkingTests/Mapper/OrderMapperTests.swift index 13c630e67d0..a07b7045f64 100644 --- a/Modules/Tests/NetworkingTests/Mapper/OrderMapperTests.swift +++ b/Modules/Tests/NetworkingTests/Mapper/OrderMapperTests.swift @@ -641,4 +641,146 @@ private extension OrderMapperTests { func mapLoadOrderWithAttributionInfo() -> Order? { return mapOrder(from: "order-with-attribution-info") } + + /// Tests that Order can decode metadata from array format + /// + func test_order_decodes_metadata_from_array_format() throws { + // Given - JSON with metadata as array + let jsonString = """ + { + "id": 12345, + "parent_id": 0, + "number": "12345", + "order_key": "wc_order_abc123", + "created_via": "rest-api", + "version": "5.0.0", + "status": "processing", + "currency": "USD", + "currency_symbol": "$", + "date_created": "2023-01-01T00:00:00", + "date_modified": "2023-01-01T00:00:00", + "discount_total": "0.00", + "discount_tax": "0.00", + "shipping_total": "0.00", + "shipping_tax": "0.00", + "cart_tax": "0.00", + "total": "10.00", + "total_tax": "0.00", + "customer_id": 0, + "billing": {}, + "shipping": {}, + "payment_method": "", + "payment_method_title": "", + "line_items": [], + "tax_lines": [], + "shipping_lines": [], + "fee_lines": [], + "coupon_lines": [], + "refunds": [], + "meta_data": [ + { + "id": 1001, + "key": "custom_field_1", + "value": "value1" + }, + { + "id": 1002, + "key": "_internal_field", + "value": "internal_value" + }, + { + "id": 1003, + "key": "custom_field_2", + "value": "value2" + } + ] + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter) + + // When + let order = try decoder.decode(Order.self, from: data) + + // Then + XCTAssertEqual(order.customFields.count, 2) // Internal field should be filtered out + XCTAssertEqual(order.customFields[0].metadataID, 1001) + XCTAssertEqual(order.customFields[0].key, "custom_field_1") + XCTAssertEqual(order.customFields[0].value.stringValue, "value1") + XCTAssertEqual(order.customFields[1].metadataID, 1003) + XCTAssertEqual(order.customFields[1].key, "custom_field_2") + XCTAssertEqual(order.customFields[1].value.stringValue, "value2") + } + + /// Tests that Order can decode metadata from dictionary format + /// + func test_Order_decodes_metadata_from_dictionary_format() throws { + // Given - JSON with metadata as object keyed by index strings + let jsonString = """ + { + "id": 12345, + "parent_id": 0, + "number": "12345", + "order_key": "wc_order_abc123", + "created_via": "rest-api", + "version": "5.0.0", + "status": "processing", + "currency": "USD", + "currency_symbol": "$", + "date_created": "2023-01-01T00:00:00", + "date_modified": "2023-01-01T00:00:00", + "discount_total": "0.00", + "discount_tax": "0.00", + "shipping_total": "0.00", + "shipping_tax": "0.00", + "cart_tax": "0.00", + "total": "10.00", + "total_tax": "0.00", + "customer_id": 0, + "billing": {}, + "shipping": {}, + "payment_method": "", + "payment_method_title": "", + "line_items": [], + "tax_lines": [], + "shipping_lines": [], + "fee_lines": [], + "coupon_lines": [], + "refunds": [], + "meta_data": { + "0": { + "id": 2001, + "key": "dict_field_1", + "value": "dict_value1" + }, + "1": { + "id": 2002, + "key": "_internal_dict_field", + "value": "internal_dict_value" + }, + "2": { + "id": 2003, + "key": "dict_field_2", + "value": "dict_value2" + } + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter) + + // When + let order = try decoder.decode(Order.self, from: data) + + // Then + XCTAssertEqual(order.customFields.count, 2) // Internal field should be filtered out + let fieldNames = Set(order.customFields.map { $0.key }) + XCTAssertTrue(fieldNames.contains("dict_field_1")) + XCTAssertTrue(fieldNames.contains("dict_field_2")) + XCTAssertFalse(fieldNames.contains("_internal_dict_field")) + } } diff --git a/Modules/Tests/NetworkingTests/Mapper/ProductMapperTests.swift b/Modules/Tests/NetworkingTests/Mapper/ProductMapperTests.swift index 69cd5518d81..aa61972779c 100644 --- a/Modules/Tests/NetworkingTests/Mapper/ProductMapperTests.swift +++ b/Modules/Tests/NetworkingTests/Mapper/ProductMapperTests.swift @@ -593,6 +593,60 @@ final class ProductMapperTests: XCTestCase { XCTAssertEqual(encodedAttributes?[index]["options"] as? [String], attribute.options) } } + + /// Test that metadata can be decoded from both array and dictionary formats + /// + func test_metadata_flexible_decoding_array_format() throws { + // Given - JSON with metadata as array (current format) + guard let data = Loader.contentsOf("minimal-product-array-metadata") else { + XCTFail("Unable to load test data") + return + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter) + decoder.userInfo[.siteID] = dummySiteID + + // When + let product = try decoder.decode(Product.self, from: data) + + // Then + XCTAssertEqual(product.customFields.count, 2) + XCTAssertEqual(product.customFields[0].metadataID, 1001) + XCTAssertEqual(product.customFields[0].key, "custom_field_1") + XCTAssertEqual(product.customFields[0].value.stringValue, "value1") + XCTAssertEqual(product.customFields[1].metadataID, 1002) + XCTAssertEqual(product.customFields[1].key, "custom_field_2") + XCTAssertEqual(product.customFields[1].value.stringValue, "value2") + } + + /// Test that metadata can be decoded from object format keyed by index strings + /// + func test_metadata_flexible_decoding_dictionary_format() throws { + // Given - JSON with metadata as object keyed by index strings (new format) + guard let data = Loader.contentsOf("minimal-product-dictionary-metadata") else { + XCTFail("Unable to load test data") + return + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter) + decoder.userInfo[.siteID] = dummySiteID + + // When + let product = try decoder.decode(Product.self, from: data) + + // Then + XCTAssertEqual(product.customFields.count, 2) + // Since dictionary values don't have a guaranteed order, we need to check by key + let field1 = product.customFields.first { $0.key == "custom_field_1" } + let field2 = product.customFields.first { $0.key == "custom_field_2" } + + XCTAssertNotNil(field1) + XCTAssertNotNil(field2) + XCTAssertEqual(field1?.metadataID, 1001) + XCTAssertEqual(field1?.value.stringValue, "value1") + XCTAssertEqual(field2?.metadataID, 1002) + XCTAssertEqual(field2?.value.stringValue, "value2") + } } diff --git a/Modules/Tests/NetworkingTests/Mapper/ProductVariationMapperTests.swift b/Modules/Tests/NetworkingTests/Mapper/ProductVariationMapperTests.swift index 10f1dd91c71..943b902b0bd 100644 --- a/Modules/Tests/NetworkingTests/Mapper/ProductVariationMapperTests.swift +++ b/Modules/Tests/NetworkingTests/Mapper/ProductVariationMapperTests.swift @@ -108,6 +108,50 @@ final class ProductVariationMapperTests: XCTestCase { XCTAssertEqual(productVariation.groupOfQuantity, "3") XCTAssertEqual(productVariation.overrideProductQuantities, true) } + + /// Test that ProductVariation metadata can be decoded from both array and dictionary formats for subscription data + /// + func test_product_variation_metadata_flexible_decoding_array_format() throws { + // Given - JSON with metadata as array (current format) containing subscription data + guard let data = Loader.contentsOf("minimal-product-variation-array-metadata") else { + XCTFail("Unable to load test data") + return + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter) + decoder.userInfo[.siteID] = dummySiteID + decoder.userInfo[.productID] = dummyProductID + + // When + let productVariation = try decoder.decode(ProductVariation.self, from: data) + + // Then - verify the subscription data was extracted from metadata + XCTAssertNotNil(productVariation.subscription) + XCTAssertEqual(productVariation.subscription?.price, "15.00") + XCTAssertEqual(productVariation.subscription?.period, .month) + } + + /// Test that ProductVariation metadata can be decoded from object format keyed by index strings + /// + func test_product_variation_metadata_flexible_decoding_dictionary_format() throws { + // Given - JSON with metadata as object keyed by index strings (new format) + guard let data = Loader.contentsOf("minimal-product-variation-dictionary-metadata") else { + XCTFail("Unable to load test data") + return + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.Defaults.dateTimeFormatter) + decoder.userInfo[.siteID] = dummySiteID + decoder.userInfo[.productID] = dummyProductID + + // When + let productVariation = try decoder.decode(ProductVariation.self, from: data) + + // Then - verify the subscription data was extracted from metadata + XCTAssertNotNil(productVariation.subscription) + XCTAssertEqual(productVariation.subscription?.price, "15.00") + XCTAssertEqual(productVariation.subscription?.period, .month) + } } /// Private Helpers diff --git a/Modules/Tests/NetworkingTests/Responses/minimal-product-array-metadata.json b/Modules/Tests/NetworkingTests/Responses/minimal-product-array-metadata.json new file mode 100644 index 00000000000..7a1d97dde6b --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/minimal-product-array-metadata.json @@ -0,0 +1,63 @@ +{ + "id": 282, + "name": "Test Product", + "slug": "test-product", + "permalink": "https://example.com/product/test-product/", + "date_created_gmt": "2019-02-19T17:33:31", + "date_modified_gmt": "2019-02-19T17:48:01", + "type": "simple", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "", + "short_description": "", + "sku": "", + "price": "10.00", + "regular_price": "10.00", + "sale_price": "", + "on_sale": false, + "purchasable": true, + "total_sales": 0, + "virtual": false, + "downloadable": false, + "downloads": [], + "download_limit": -1, + "download_expiry": -1, + "button_text": "", + "external_url": "", + "tax_status": "taxable", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "stock_status": "instock", + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "sold_individually": false, + "weight": "", + "dimensions": {"length": "", "width": "", "height": ""}, + "shipping_required": false, + "shipping_taxable": false, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "0.00", + "rating_count": 0, + "related_ids": [], + "upsell_ids": [], + "cross_sell_ids": [], + "parent_id": 0, + "purchase_note": "", + "categories": [], + "tags": [], + "images": [], + "attributes": [], + "default_attributes": [], + "variations": [], + "grouped_products": [], + "menu_order": 0, + "meta_data": [ + {"id": 1001, "key": "custom_field_1", "value": "value1"}, + {"id": 1002, "key": "custom_field_2", "value": "value2"} + ] +} \ No newline at end of file diff --git a/Modules/Tests/NetworkingTests/Responses/minimal-product-dictionary-metadata.json b/Modules/Tests/NetworkingTests/Responses/minimal-product-dictionary-metadata.json new file mode 100644 index 00000000000..5615f999505 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/minimal-product-dictionary-metadata.json @@ -0,0 +1,63 @@ +{ + "id": 282, + "name": "Test Product", + "slug": "test-product", + "permalink": "https://example.com/product/test-product/", + "date_created_gmt": "2019-02-19T17:33:31", + "date_modified_gmt": "2019-02-19T17:48:01", + "type": "simple", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "", + "short_description": "", + "sku": "", + "price": "10.00", + "regular_price": "10.00", + "sale_price": "", + "on_sale": false, + "purchasable": true, + "total_sales": 0, + "virtual": false, + "downloadable": false, + "downloads": [], + "download_limit": -1, + "download_expiry": -1, + "button_text": "", + "external_url": "", + "tax_status": "taxable", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "stock_status": "instock", + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "sold_individually": false, + "weight": "", + "dimensions": {"length": "", "width": "", "height": ""}, + "shipping_required": false, + "shipping_taxable": false, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "0.00", + "rating_count": 0, + "related_ids": [], + "upsell_ids": [], + "cross_sell_ids": [], + "parent_id": 0, + "purchase_note": "", + "categories": [], + "tags": [], + "images": [], + "attributes": [], + "default_attributes": [], + "variations": [], + "grouped_products": [], + "menu_order": 0, + "meta_data": { + "0": {"id": 1001, "key": "custom_field_1", "value": "value1"}, + "1": {"id": 1002, "key": "custom_field_2", "value": "value2"} + } +} \ No newline at end of file diff --git a/Modules/Tests/NetworkingTests/Responses/minimal-product-variation-array-metadata.json b/Modules/Tests/NetworkingTests/Responses/minimal-product-variation-array-metadata.json new file mode 100644 index 00000000000..f18be48e1cc --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/minimal-product-variation-array-metadata.json @@ -0,0 +1,37 @@ +{ + "id": 325, + "date_created_gmt": "2019-02-19T17:33:31", + "date_modified_gmt": "2019-02-19T17:48:01", + "status": "publish", + "description": "", + "sku": "", + "price": "12.00", + "regular_price": "12.00", + "sale_price": "", + "on_sale": false, + "purchasable": true, + "virtual": false, + "downloadable": false, + "downloads": [], + "download_limit": -1, + "download_expiry": -1, + "tax_status": "taxable", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "stock_status": "instock", + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "weight": "", + "dimensions": {"length": "", "width": "", "height": ""}, + "shipping_class": "", + "shipping_class_id": 0, + "image": null, + "attributes": [], + "menu_order": 0, + "meta_data": [ + {"id": 3001, "key": "_subscription_price", "value": "15.00"}, + {"id": 3002, "key": "_subscription_period", "value": "month"} + ] +} \ No newline at end of file diff --git a/Modules/Tests/NetworkingTests/Responses/minimal-product-variation-dictionary-metadata.json b/Modules/Tests/NetworkingTests/Responses/minimal-product-variation-dictionary-metadata.json new file mode 100644 index 00000000000..10af3330ece --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/minimal-product-variation-dictionary-metadata.json @@ -0,0 +1,37 @@ +{ + "id": 325, + "date_created_gmt": "2019-02-19T17:33:31", + "date_modified_gmt": "2019-02-19T17:48:01", + "status": "publish", + "description": "", + "sku": "", + "price": "12.00", + "regular_price": "12.00", + "sale_price": "", + "on_sale": false, + "purchasable": true, + "virtual": false, + "downloadable": false, + "downloads": [], + "download_limit": -1, + "download_expiry": -1, + "tax_status": "taxable", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "stock_status": "instock", + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "weight": "", + "dimensions": {"length": "", "width": "", "height": ""}, + "shipping_class": "", + "shipping_class_id": 0, + "image": null, + "attributes": [], + "menu_order": 0, + "meta_data": { + "0": {"id": 3001, "key": "_subscription_price", "value": "15.00"}, + "1": {"id": 3002, "key": "_subscription_period", "value": "month"} + } +} \ No newline at end of file diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 33e9c87e99f..6728159a524 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -6,6 +6,7 @@ - [*] Order details: Display only physical items in the Shipping Labels section. [https://github.com/woocommerce/woocommerce-ios/pull/16127] - [internal] Address deprecated view modifiers usage following iOS17 API updates [https://github.com/woocommerce/woocommerce-ios/pull/16080] - [internal] POS Modularization: Removed direct ServiceLocator usage within POS by requiring complex Woo app target dependencies to be injected via POS dependency protocols, and moved reusable dependencies to WooFoundation and Yosemite [https://github.com/woocommerce/woocommerce-ios/pull/16132] +- [*] Workaround to make custom field decoding more reliable with sites which return incorrectly formatted meta_data for products, variations, and orders. [https://github.com/woocommerce/woocommerce-ios/pull/16141] 23.2 -----