Skip to content
Merged
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
35 changes: 35 additions & 0 deletions UnitTests/ObjCTests/MPConvertJSTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
#import "MPConvertJS.h"
#import "mParticle.h"
#import "MPBaseTestCase.h"
#import "MPPromotion.h"
#import "MPPromotion+Dictionary.h"
#import "MPTransactionAttributes+Dictionary.h"

@interface MPConvertJSTests : MPBaseTestCase

Expand Down Expand Up @@ -30,6 +33,38 @@ - (void)testConvertTransaction {
XCTAssertEqualObjects(transactionAttributes.transactionId, @"Test transaction id");
}

- (void)testConvertTransactionWithNoValidFieldsLeavesAttributesNil {
// Every field either absent or a non-string/non-number, so nothing should
// ever touch MPTransactionAttributes's lazy attributes dictionary.
NSDictionary *json = @{
@"ShippingAmount":[NSNull null],
@"TaxAmount":[NSNull null]
};

MPTransactionAttributes *transactionAttributes = [MPConvertJS_PRIVATE transactionAttributes:json];
XCTAssertNotNil(transactionAttributes);
XCTAssertNil([transactionAttributes dictionaryRepresentation],
@"A transaction with no valid fields must serialize to nil, not an empty dictionary.");
}

- (void)testConvertPromotionWithNoValidFieldsLeavesAttributesNil {
// Regression test for the case Brandon's review on #929 flagged: an
// unconditional setter call still allocates MPPromotion's lazy attributes
// dictionary even when the value being assigned is nil, turning "no valid
// fields" into an empty (non-nil) dictionaryRepresentation that
// MPPromotionContainer no longer skips.
NSDictionary *json = @{
@"Creative":[NSNull null],
@"Name":[NSNull null]
};

MPPromotion *promotion = [MPConvertJS_PRIVATE promotion:json];
XCTAssertNotNil(promotion);
XCTAssertNil([promotion dictionaryRepresentation],
@"A promotion with no valid fields must serialize to nil, not an empty dictionary — "
@"otherwise MPPromotionContainer stops skipping it and ships an empty promotion.");
}

- (void)testConvertMPCommerceEventProductAction {
NSDictionary *customFlags = @{
@"customFlag1": @"flag1value",
Expand Down
119 changes: 119 additions & 0 deletions mParticle-Apple-SDK-Swift/Sources/Utils/MPConvertJSFields.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import Foundation

/// The strings the webview bridge sends for one promotion.
@objc(MPPromotionFieldsJS)
public final class MPPromotionFieldsJS: NSObject {
@objc public let creative: String?
@objc public let name: String?
@objc public let position: String?
@objc public let promotionId: String?

init(creative: String?, name: String?, position: String?, promotionId: String?) {
self.creative = creative
self.name = name
self.position = position
self.promotionId = promotionId
super.init()
}
}

/// The values the webview bridge sends for one set of transaction attributes.
@objc(MPTransactionAttributesFieldsJS)
public final class MPTransactionAttributesFieldsJS: NSObject {
@objc public let affiliation: String?
@objc public let couponCode: String?
@objc public let shipping: NSNumber?
@objc public let tax: NSNumber?
@objc public let revenue: NSNumber?
@objc public let transactionId: String?

init(
affiliation: String?,
couponCode: String?,
shipping: NSNumber?,
tax: NSNumber?,
revenue: NSNumber?,
transactionId: String?
) {
self.affiliation = affiliation
self.couponCode = couponCode
self.shipping = shipping
self.tax = tax
self.revenue = revenue
self.transactionId = transactionId
super.init()
}
}

/// Reads the webview bridge's JSON payloads. Building the SDK types themselves
/// stays in MPConvertJS.m: this module cannot see MPPromotion, MPCommerceEvent
/// or the rest, per docs/swift-migration/CONVERSION-RECIPE.md.
@objc(MPConvertJSFields)
public final class MPConvertJSFields: NSObject {
/// Mirrors MPJSCommerceEventAction (MPConvertJS.h) on the way in and
/// MPCommerceEventAction (MPCommerceEvent.h) on the way out. The two enums
/// do not share an order, so this is a genuine remap rather than a cast.
///
/// Returns nil for a value the bridge does not define — including its own
/// `Unknown` (0) — so the caller keeps logging before falling back to
/// AddToCart, exactly as the Objective-C switch's `default:` did.
@objc(commerceEventActionForJSValue:)
public static func commerceEventAction(forJSValue value: Any?) -> NSNumber? {
// -intValue, so a string-encoded action behaves as it did in ObjC.
guard let raw = MPJSONCoercion.integerValue(value) else {
return nil
}

let action: Int? = switch raw {
case 1: 0 // AddToCart
case 2: 1 // RemoveFromCart
case 3: 4 // Checkout
case 4: 5 // CheckoutOptions
case 5: 6 // Click
case 6: 7 // ViewDetail
case 7: 8 // Purchase
case 8: 9 // Refund
case 9: 2 // AddToWishList
case 10: 3 // RemoveFromWishlist
default: nil
}

return action.map { NSNumber(value: $0) }
}

@objc(promotionFieldsFromJSON:)
public static func promotionFields(from json: [AnyHashable: Any]?) -> MPPromotionFieldsJS {
MPPromotionFieldsJS(
creative: string(json?["Creative"]),
name: string(json?["Name"]),
position: string(json?["Position"]),
promotionId: string(json?["Id"])
)
}

@objc(transactionAttributesFieldsFromJSON:)
public static func transactionAttributesFields(
from json: [AnyHashable: Any]?
) -> MPTransactionAttributesFieldsJS {
MPTransactionAttributesFieldsJS(
affiliation: string(json?["Affiliation"]),
couponCode: string(json?["CouponCode"]),
shipping: number(json?["ShippingAmount"]),
tax: number(json?["TaxAmount"]),
revenue: number(json?["TotalAmount"]),
transactionId: string(json?["TransactionId"])
)
}

/// `-isKindOfClass:[NSString class]`: a number or a null is dropped rather
/// than coerced, which is what the ObjC guards did.
private static func string(_ value: Any?) -> String? {
value as? String
}

/// `-isKindOfClass:[NSNumber class]`. Note a Swift `Bool` bridges to
/// NSNumber, matching ObjC, where `@YES` is an NSNumber too.
private static func number(_ value: Any?) -> NSNumber? {
value as? NSNumber
}
}
158 changes: 158 additions & 0 deletions mParticle-Apple-SDK-Swift/Test/Utils/MPConvertJSFieldsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import XCTest
@testable import mParticle_Apple_SDK_Swift

final class MPConvertJSFieldsTests: XCTestCase {
// MARK: - commerce event action

/// MPJSCommerceEventAction and MPCommerceEventAction do not share an order,
/// so every pair is spelled out rather than trusting a cast.
func testCommerceEventActionRemapsEveryDefinedValue() {
let expected: [Int: Int] = [
1: 0, // AddToCart
2: 1, // RemoveFromCart
3: 4, // Checkout
4: 5, // CheckoutOptions
5: 6, // Click
6: 7, // ViewDetail
7: 8, // Purchase
8: 9, // Refund
9: 2, // AddToWishList
10: 3 // RemoveFromWishlist
]

for (js, core) in expected {
XCTAssertEqual(
MPConvertJSFields.commerceEventAction(forJSValue: NSNumber(value: js)),
NSNumber(value: core),
"JS action \(js) should map to core action \(core)"
)
}
}

func testCommerceEventActionIsNilForUnknownAndUndefinedValues() {
// 0 is MPJSCommerceEventActionUnknown, which the ObjC switch also let
// fall through to default:. nil is how the caller knows to log.
for raw in [0, 11, 99, -1] {
XCTAssertNil(MPConvertJSFields.commerceEventAction(forJSValue: NSNumber(value: raw)))
}
XCTAssertNil(MPConvertJSFields.commerceEventAction(forJSValue: nil))
}

func testCommerceEventActionAcceptsAStringEncodedValue() {
// The ObjC read the value with -intValue, which NSString answers too,
// and the parameter is declared NSNumber* without ObjC enforcing it.
XCTAssertEqual(
MPConvertJSFields.commerceEventAction(forJSValue: "7"),
NSNumber(value: 8) // Purchase
)
XCTAssertNil(MPConvertJSFields.commerceEventAction(forJSValue: "not a number"))
XCTAssertNil(MPConvertJSFields.commerceEventAction(forJSValue: ["unexpected": "shape"]))
}

// MARK: - promotion

func testPromotionFieldsReadsEveryString() {
let fields = MPConvertJSFields.promotionFields(from: [
"Creative": "banner",
"Name": "summer sale",
"Position": "top",
"Id": "promo-1"
])

XCTAssertEqual(fields.creative, "banner")
XCTAssertEqual(fields.name, "summer sale")
XCTAssertEqual(fields.position, "top")
XCTAssertEqual(fields.promotionId, "promo-1")
}

func testPromotionFieldsDropsNonStringsAndNulls() {
// -isKindOfClass:[NSString class] rejected these rather than coercing.
let fields = MPConvertJSFields.promotionFields(from: [
"Creative": 42,
"Name": NSNull(),
"Position": ["nested": "value"],
"Id": true
])

XCTAssertNil(fields.creative)
XCTAssertNil(fields.name)
XCTAssertNil(fields.position)
XCTAssertNil(fields.promotionId)
}

func testPromotionFieldsToleratesAnEmptyOrMissingPayload() {
for json: [AnyHashable: Any]? in [[:], nil] {
let fields = MPConvertJSFields.promotionFields(from: json)

XCTAssertNil(fields.creative)
XCTAssertNil(fields.name)
XCTAssertNil(fields.position)
XCTAssertNil(fields.promotionId)
}
}

func testPromotionFieldsKeepsTheEmptyString() {
// An empty string is still an NSString, so the ObjC assigned it.
XCTAssertEqual(MPConvertJSFields.promotionFields(from: ["Name": ""]).name, "")
}

// MARK: - transaction attributes

func testTransactionAttributesFieldsReadsEveryValue() {
let fields = MPConvertJSFields.transactionAttributesFields(from: [
"Affiliation": "store",
"CouponCode": "SAVE10",
"ShippingAmount": 4.5,
"TaxAmount": 1.25,
"TotalAmount": 99.99,
"TransactionId": "txn-1"
])

XCTAssertEqual(fields.affiliation, "store")
XCTAssertEqual(fields.couponCode, "SAVE10")
XCTAssertEqual(fields.shipping, NSNumber(value: 4.5))
XCTAssertEqual(fields.tax, NSNumber(value: 1.25))
XCTAssertEqual(fields.revenue, NSNumber(value: 99.99))
XCTAssertEqual(fields.transactionId, "txn-1")
}

func testTransactionAttributesFieldsRejectsMismatchedTypes() {
// Numbers in string slots and strings in number slots were both dropped.
let fields = MPConvertJSFields.transactionAttributesFields(from: [
"Affiliation": 7,
"CouponCode": NSNull(),
"ShippingAmount": "4.5",
"TaxAmount": NSNull(),
"TotalAmount": ["a": 1],
"TransactionId": 12345
])

XCTAssertNil(fields.affiliation)
XCTAssertNil(fields.couponCode)
XCTAssertNil(fields.shipping)
XCTAssertNil(fields.tax)
XCTAssertNil(fields.revenue)
XCTAssertNil(fields.transactionId)
}

func testTransactionAttributesFieldsToleratesAnEmptyOrMissingPayload() {
for json: [AnyHashable: Any]? in [[:], nil] {
let fields = MPConvertJSFields.transactionAttributesFields(from: json)

XCTAssertNil(fields.affiliation)
XCTAssertNil(fields.revenue)
XCTAssertNil(fields.transactionId)
}
}

func testTransactionAttributesFieldsKeepsZeroAmounts() {
// Zero is a real NSNumber, and the setters distinguish it from absent.
let fields = MPConvertJSFields.transactionAttributesFields(from: [
"ShippingAmount": 0,
"TotalAmount": 0.0
])

XCTAssertEqual(fields.shipping, NSNumber(value: 0))
XCTAssertEqual(fields.revenue, NSNumber(value: 0.0))
}
}
2 changes: 2 additions & 0 deletions mParticle-Apple-SDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@
Test/Utils/MPConsentKitFilterTests.swift,
Test/Utils/MPConsentRecordTests.swift,
Test/Utils/MPConsentStateTests.swift,
Test/Utils/MPConvertJSFieldsTests.swift,
Test/Utils/MPCustomModulePreferenceLogicTests.swift,
Test/Utils/MPCustomModulePreferenceTests.swift,
Test/Utils/MPCustomModuleTests.swift,
Expand Down Expand Up @@ -666,6 +667,7 @@
Test/Utils/MPConsentKitFilterTests.swift,
Test/Utils/MPConsentRecordTests.swift,
Test/Utils/MPConsentStateTests.swift,
Test/Utils/MPConvertJSFieldsTests.swift,
Test/Utils/MPCustomModulePreferenceLogicTests.swift,
Test/Utils/MPCustomModulePreferenceTests.swift,
Test/Utils/MPCustomModuleTests.swift,
Expand Down
Loading
Loading