From 02a0f52e2cdf2533cafa65502757418e2397bc65 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:28:26 +0300 Subject: [PATCH 1/5] MOBILE-303: Decode promo actions from the plural promoActions key The API returns promoActions, but the model decoded the singular promoAction key, so decodeIfPresent silently dropped the array ever since the field was added in 2021. Hybrid bridges (React Native) re-encode the decoded model before handing it to JS, so the promo actions never reached the app at all. The explicit encode(to:) keeps the re-encoded JSON on the same wire keys as the decoder; without it the compiler synthesizes encoding from property names, which is exactly what hid this mismatch. The public promoAction property keeps its name to avoid an API break, and productListItems intentionally keeps its historical encode-only key. --- Mindbox/Model/OperationResponse.swift | 30 +++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/Mindbox/Model/OperationResponse.swift b/Mindbox/Model/OperationResponse.swift index ca647a859..e422f7a91 100644 --- a/Mindbox/Model/OperationResponse.swift +++ b/Mindbox/Model/OperationResponse.swift @@ -42,14 +42,38 @@ 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. + case productListItems case recommendations case customerSegmentations case setProductCountInList @@ -57,7 +81,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 } } From 24b00044b8526d1642a2df07473f32d4eda00345 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:28:41 +0300 Subject: [PATCH 2/5] MOBILE-303: Cover the OperationResponse wire-key contract Locks down the promoActions decode/re-encode key, the untouched wire keys, and both productList shapes, so the next contract drift fails a test instead of silently dropping a field. --- Mindbox.xcodeproj/project.pbxproj | 4 + .../Network/OperationResponseTests.swift | 131 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 MindboxTests/Network/OperationResponseTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 6f31cb621..18453a311 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/MindboxTests/Network/OperationResponseTests.swift b/MindboxTests/Network/OperationResponseTests.swift new file mode 100644 index 000000000..526ed1f3c --- /dev/null +++ b/MindboxTests/Network/OperationResponseTests.swift @@ -0,0 +1,131 @@ +// +// 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")) + } + + @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) + } +} From 47fa728649dc97fb17c7b6e1c78bca2e5466de0d Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:40:51 +0300 Subject: [PATCH 3/5] MOBILE-303: Mark promoAction for rename to promoActions in 3.0 --- Mindbox/Model/OperationResponse.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Mindbox/Model/OperationResponse.swift b/Mindbox/Model/OperationResponse.swift index e422f7a91..8b79db7f3 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? From 7e9d191dd2e00f6de6385cc0ed337751a8463d92 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:49:23 +0300 Subject: [PATCH 4/5] MOBILE-303: Mark the productListItems encode-only key for removal in 3.0 --- Mindbox/Model/OperationResponse.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Mindbox/Model/OperationResponse.swift b/Mindbox/Model/OperationResponse.swift index 8b79db7f3..0a7edfd70 100644 --- a/Mindbox/Model/OperationResponse.swift +++ b/Mindbox/Model/OperationResponse.swift @@ -75,6 +75,8 @@ open class OperationResponse: OperationResponseType { // 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 From 3461580675b7a2711bf8e476fdc68ed3119fa4d8 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:34:34 +0300 Subject: [PATCH 5/5] MOBILE-303: Cover a production-shaped promo-actions payload Anonymized shape of the sync-operation response from the client report: fractional seconds longer than the .SSS parse pattern (ICU truncates, doesn't shift), sibling non-Utc date keys, and boolean custom fields that must survive re-encoding. --- .../Network/OperationResponseTests.swift | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/MindboxTests/Network/OperationResponseTests.swift b/MindboxTests/Network/OperationResponseTests.swift index 526ed1f3c..e372192d3 100644 --- a/MindboxTests/Network/OperationResponseTests.swift +++ b/MindboxTests/Network/OperationResponseTests.swift @@ -121,6 +121,85 @@ struct OperationResponseTests { #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,