diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 6f31cb62..18453a31 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -450,6 +450,7 @@ A1D017F52976FC2B00CD9F99 /* InternalTargetingChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D017F42976FC2B00CD9F99 /* InternalTargetingChecker.swift */; }; A1D23AF029DE082E00A75179 /* InAppProductSegmentResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D23AEF29DE082E00A75179 /* InAppProductSegmentResponse.swift */; }; AF174B2121221D323FB95EF0 /* MBEventRepositorySendRawTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */; }; + 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */; }; B36D57852696E59400FEDFD6 /* RetailOrderStatisticsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36D57842696E59400FEDFD6 /* RetailOrderStatisticsResponse.swift */; }; B3A6254C2689F83100B6A3B7 /* PersonalOffersResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A6254B2689F83100B6A3B7 /* PersonalOffersResponse.swift */; }; B3A625502689F8B600B6A3B7 /* BenefitResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A6254F2689F8B600B6A3B7 /* BenefitResponse.swift */; }; @@ -1225,6 +1226,7 @@ BD1BE43AA9EAEA03F8ED400C /* HapticRequestParserTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestParserTests.swift; sourceTree = ""; }; BD1BE43AA9EAEA03F8ED400D /* HapticRequestValidatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestValidatorTests.swift; sourceTree = ""; }; BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBEventRepositorySendRawTests.swift; sourceTree = ""; }; + 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OperationResponseTests.swift; sourceTree = ""; }; BF1A11C4A4B940898BA80035 /* DateFormatMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatMigrationTests.swift; sourceTree = ""; }; D216DE502C0716B70020F58A /* StringExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringExtensionsTests.swift; sourceTree = ""; }; D216DE522C0716B80020F58A /* TimeIntervalTimeSpanTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeIntervalTimeSpanTests.swift; sourceTree = ""; }; @@ -2299,6 +2301,7 @@ F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, F3CD202C2F600A800065392A /* HostNormalizerTests.swift */, BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */, + 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */, ); path = Network; sourceTree = ""; @@ -4854,6 +4857,7 @@ F3BA5E000130A000C0000005 /* OperationsURLRoutingTests.swift in Sources */, F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */, AF174B2121221D323FB95EF0 /* MBEventRepositorySendRawTests.swift in Sources */, + 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */, EA395B77BB16CEFE6DC91D1D /* TransparentViewSyncOperationResponseTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mindbox/Model/OperationResponse.swift b/Mindbox/Model/OperationResponse.swift index ca647a85..0a7edfd7 100644 --- a/Mindbox/Model/OperationResponse.swift +++ b/Mindbox/Model/OperationResponse.swift @@ -21,6 +21,8 @@ open class OperationResponse: OperationResponseType { public let personalOffers: [PersonalOffersResponse]? public let balances: [BalanceResponse]? public let discountCards: [DiscountCardResponse]? + // TODO: MOBILE-303 — rename to `promoActions` in 3.0: the API key is plural, + // the singular property name is a source-breaking legacy we keep until a major release. public let promoAction: [PromoActionsResponse]? public let retailOrderStatistics: RetailOrderStatisticsResponse? @@ -42,14 +44,40 @@ open class OperationResponse: OperationResponseType { personalOffers = try container.decodeIfPresent([PersonalOffersResponse].self, forKey: .personalOffers) balances = try container.decodeIfPresent([BalanceResponse].self, forKey: .balances) discountCards = try container.decodeIfPresent([DiscountCardResponse].self, forKey: .discountCards) - promoAction = try container.decodeIfPresent([PromoActionsResponse].self, forKey: .promoAction) + promoAction = try container.decodeIfPresent([PromoActionsResponse].self, forKey: .promoActions) retailOrderStatistics = try container.decodeIfPresent(RetailOrderStatisticsResponse.self, forKey: .retailOrderStatistics) } + // `encode(to:)` must stay in sync with `Keys` — the compiler can't synthesize it + // from this enum (it isn't named `CodingKeys`), and synthesized encoding by + // property names is exactly what hid the promoAction/promoActions mismatch. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: Keys.self) + try container.encode(status, forKey: .status) + try container.encodeIfPresent(customer, forKey: .customer) + try container.encodeIfPresent(productList, forKey: .productList) + try container.encodeIfPresent(productListItems, forKey: .productListItems) + try container.encodeIfPresent(recommendations, forKey: .recommendations) + try container.encodeIfPresent(customerSegmentations, forKey: .customerSegmentations) + try container.encodeIfPresent(setProductCountInList, forKey: .setProductCountInList) + try container.encodeIfPresent(promoCode, forKey: .promoCode) + try container.encodeIfPresent(personalOffers, forKey: .personalOffers) + try container.encodeIfPresent(balances, forKey: .balances) + try container.encodeIfPresent(discountCards, forKey: .discountCards) + try container.encodeIfPresent(promoAction, forKey: .promoActions) + try container.encodeIfPresent(retailOrderStatistics, forKey: .retailOrderStatistics) + } + enum Keys: String, CodingKey { case status case customer case productList + // Encode-only: both productList shapes decode from the `productList` wire key, + // but have always re-encoded under their own property names — kept that way + // so the bridge payload only gains promoActions, nothing else moves. + // TODO: MOBILE-303 — drop this key in 3.0 and re-encode both shapes under + // `productList`, making the bridge payload fully wire-faithful (as on Android). + case productListItems case recommendations case customerSegmentations case setProductCountInList @@ -57,7 +85,9 @@ open class OperationResponse: OperationResponseType { case personalOffers case balances case discountCards - case promoAction + // The API sends the plural key; the `promoAction` property keeps its + // historical singular name because renaming it would break the public SDK surface. + case promoActions case retailOrderStatistics } } diff --git a/MindboxTests/Network/OperationResponseTests.swift b/MindboxTests/Network/OperationResponseTests.swift new file mode 100644 index 00000000..e372192d --- /dev/null +++ b/MindboxTests/Network/OperationResponseTests.swift @@ -0,0 +1,210 @@ +// +// OperationResponseTests.swift +// MindboxTests +// +// Created by Sergei Semko on 16.07.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import Foundation +@testable import Mindbox + +/// Wire-contract tests for `OperationResponse` (MOBILE-303): the API returns promo +/// actions under the plural `promoActions` key, and the JSON re-encoded for the +/// hybrid bridges (`createJSON`) must use the same wire keys as the decoder — +/// otherwise a field silently vanishes between the API and the JS layer. +@Suite("OperationResponse wire contract", .tags(.decoding, .customOperation)) +struct OperationResponseTests { + + /// Response shaped like the documented `get-promotions-for-customer` payload. + private static let responseJSON = Data(""" + { + "status": "Success", + "promoActions": [ + { + "ids": { "externalId": "summer-sale" }, + "name": "Summer sale", + "description": "10% off everything", + "startDateTimeUtc": "2026-06-01T00:00:00Z", + "endDateTimeUtc": "2026-08-31T23:59:59Z", + "customFields": { "testCustomField": "value" }, + "limits": [ + { + "type": "personalLimit", + "untilDateTimeUtc": "2026-08-31T23:59:59Z", + "amount": { "type": "absolute", "value": 3 }, + "used": { "amount": 1 } + } + ] + }, + { "name": "Second action" } + ], + "promoCode": { "isUsed": false }, + "balances": [ { "total": 100, "available": 90 } ], + "discountCards": [ { "ids": { "number": "1234" } } ] + } + """.utf8) + + private func decodeResponse() throws -> OperationResponse { + try JSONDecoder().decode(OperationResponse.self, from: Self.responseJSON) + } + + private func reEncodedDictionary(of response: OperationResponse) throws -> [String: Any] { + let json = try #require(response.createJSON().data(using: .utf8)) + return try #require(try JSONSerialization.jsonObject(with: json) as? [String: Any]) + } + + @Test("Promo actions are decoded from the plural promoActions wire key") + func decodesPromoActionsFromPluralKey() throws { + let response = try decodeResponse() + + let actions = try #require(response.promoAction, + "the API sends promoActions (plural); the array must not be silently dropped") + try #require(actions.count == 2) + #expect(actions[0].name == "Summer sale") + #expect(actions[0].description == "10% off everything") + #expect(actions[0].ids?["externalId"] == "summer-sale") + #expect(actions[0].startDateTimeUtc != nil) + #expect(actions[0].endDateTimeUtc != nil) + #expect(actions[0].limits?.count == 1) + #expect(actions[1].name == "Second action") + } + + @Test("createJSON re-encodes promo actions under the promoActions wire key") + func createJSONKeepsPluralWireKey() throws { + let response = try decodeResponse() + + let dict = try reEncodedDictionary(of: response) + + #expect(!dict.keys.contains("promoAction"), + "the singular key never existed on the wire and must not leak into re-encoded JSON") + let actions = try #require(dict["promoActions"] as? [[String: Any]]) + try #require(actions.count == 2) + #expect(actions[0]["name"] as? String == "Summer sale") + #expect(actions[1]["name"] as? String == "Second action") + } + + @Test("createJSON keeps every other wire key unchanged") + func createJSONKeepsOtherWireKeys() throws { + let response = try decodeResponse() + + let dict = try reEncodedDictionary(of: response) + + #expect(Set(dict.keys) == ["status", "promoActions", "promoCode", "balances", "discountCards"]) + } + + // The two productList shapes share one wire key on decode, but have always + // re-encoded under their own property names (synthesized behavior). Locked + // down here so the MOBILE-303 encode(to:) rewrite changes nothing but promoActions. + @Test("An array productList re-encodes under the productList key") + func productListArrayKeepsItsKey() throws { + let response = try JSONDecoder().decode( + OperationResponse.self, + from: Data(#"{"status": "Success", "productList": [{"count": 2, "price": 100}]}"#.utf8)) + + try #require(response.productList?.count == 1) + let dict = try reEncodedDictionary(of: response) + #expect((dict["productList"] as? [[String: Any]])?.count == 1) + #expect(!dict.keys.contains("productListItems")) + } + + @Test("An object productList re-encodes under the productListItems key") + func productListObjectKeepsItemsKey() throws { + let response = try JSONDecoder().decode( + OperationResponse.self, + from: Data(#"{"status": "Success", "productList": {"items": [{"priceForCustomer": 5}]}}"#.utf8)) + + try #require(response.productListItems?.items?.count == 1) + let dict = try reEncodedDictionary(of: response) + #expect((dict["productListItems"] as? [String: Any]) != nil) + #expect(!dict.keys.contains("productList")) + } + + /// Shape of a production sync-operation response reported in MOBILE-303, fully + /// anonymized: fractional seconds longer than the `.SSS` parse pattern (6 and 5 + /// digits), sibling non-Utc date keys, `timeZoneMode`, and custom fields mixing + /// booleans with strings. All values are synthetic; the key set and the date + /// string formats are what mirror the real payload. + private static let productionShapedJSON = Data(""" + { + "status": "Success", + "promoActions": [ + { + "ids": { "externalId": "promo-12345678" }, + "description": "First promo description", + "endDateTime": "2026-12-31T21:00:00Z", + "endDateTimeUtc": "2026-12-31T21:00:00Z", + "name": "First promo", + "startDateTime": "2026-01-02T03:04:05.256574Z", + "startDateTimeUtc": "2026-01-02T03:04:05.256574Z", + "timeZoneMode": "project", + "customFields": { + "offerEnabled": true, + "offerPlacement": "top", + "imageUrl": "https://example.com/promo-1.png", + "inAppOperation": "Mobile.SomeOperation", + "modalVariant": "text", + "buttonText": "Accept", + "actionUrl": "https://example.com", + "formId": "1" + } + }, + { + "ids": { "externalId": "promo-87654321" }, + "description": "Second promo description", + "endDateTime": "2026-12-31T21:00:00Z", + "endDateTimeUtc": "2026-12-31T21:00:00Z", + "name": "Second promo", + "startDateTime": "2026-01-02T03:04:06.94785Z", + "startDateTimeUtc": "2026-01-02T03:04:06.94785Z", + "timeZoneMode": "project", + "customFields": { + "offerEnabled": true, + "offerPlacement": "top", + "imageUrl": "https://example.com/promo-2.webp", + "inAppOperation": "Mobile.SomeOperation", + "modalVariant": "image", + "modalImageUrl": "https://example.com/modal-2.jpg", + "buttonText": "Get two", + "actionUrl": "https://example.com", + "formId": "2" + } + } + ] + } + """.utf8) + + @Test("A production-shaped payload decodes and survives re-encoding") + func decodesProductionShapedPayload() throws { + let response = try JSONDecoder().decode(OperationResponse.self, from: Self.productionShapedJSON) + + let actions = try #require(response.promoAction) + try #require(actions.count == 2) + #expect(actions[0].name == "First promo") + #expect(actions[0].ids?["externalId"] == "promo-12345678") + + // ICU truncates fractional seconds beyond 3 digits on parse (.256574 → .256); + // a regression to "N digits = N milliseconds" would shift this by ~4 minutes. + let reference = try #require(ISO8601DateFormatter().date(from: "2026-01-02T03:04:05Z")) + let start = try #require(actions[0].startDateTimeUtc).date + #expect(abs(start.timeIntervalSince(reference)) < 1.0) + + let dict = try reEncodedDictionary(of: response) + let encodedActions = try #require(dict["promoActions"] as? [[String: Any]]) + try #require(encodedActions.count == 2) + // CustomFields must round-trip JSON types untouched — booleans stay booleans. + let customFields = try #require(encodedActions[0]["customFields"] as? [String: Any]) + #expect(customFields["offerEnabled"] as? Bool == true) + #expect(customFields["offerPlacement"] as? String == "top") + #expect(customFields["formId"] as? String == "1") + } + + @Test("A response without promo actions decodes with a nil field and no error") + func decodesWithoutPromoActions() throws { + let response = try JSONDecoder().decode(OperationResponse.self, + from: Data(#"{"status": "Success"}"#.utf8)) + #expect(response.promoAction == nil) + #expect(response.status == .success) + } +}