Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Mindbox.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1225,6 +1226,7 @@
BD1BE43AA9EAEA03F8ED400C /* HapticRequestParserTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestParserTests.swift; sourceTree = "<group>"; };
BD1BE43AA9EAEA03F8ED400D /* HapticRequestValidatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestValidatorTests.swift; sourceTree = "<group>"; };
BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBEventRepositorySendRawTests.swift; sourceTree = "<group>"; };
10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OperationResponseTests.swift; sourceTree = "<group>"; };
BF1A11C4A4B940898BA80035 /* DateFormatMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatMigrationTests.swift; sourceTree = "<group>"; };
D216DE502C0716B70020F58A /* StringExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringExtensionsTests.swift; sourceTree = "<group>"; };
D216DE522C0716B80020F58A /* TimeIntervalTimeSpanTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeIntervalTimeSpanTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2299,6 +2301,7 @@
F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */,
F3CD202C2F600A800065392A /* HostNormalizerTests.swift */,
BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */,
10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */,
);
path = Network;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
Expand Down
34 changes: 32 additions & 2 deletions Mindbox/Model/OperationResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -42,22 +44,50 @@ 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
case promoCode
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
}
}
Expand Down
210 changes: 210 additions & 0 deletions MindboxTests/Network/OperationResponseTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}