diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index cda423a..da15077 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -28,12 +28,23 @@ - (void)applyCommerceEventMetadata:(MPCommerceEvent *)event fromDictionary:(NSDi - (void)addPromotionsFromDicts:(NSArray *)promotionDicts toCommerceEvent:(MPCommerceEvent *)event; @end -// Forward declare so New Arch `logCommerceEvent` can use the same JS→native -// mappings as `RCTConvert (MPCommerceEvent)` (defined later in this file). -@interface RCTConvert (MPCommerceEvent) -+ (MPCommerceEventAction)MPCommerceEventAction:(id)json; -+ (MPPromotionAction)MPPromotionAction:(id)json; +// The single set of JS -> native converters for this module, implemented at the +// bottom of this file. Declared here so the New Architecture methods above can +// reuse the same mappings as the legacy bridge. Parameter types match the +// implementations exactly -- do not widen them to `id` without changing both. +@interface RCTConvert (RNMParticle) ++ (MPEvent *)MPEvent:(NSDictionary *)dict; ++ (MPAliasRequest *)MPAliasRequest:(NSDictionary *)dict; ++ (MPCommerceEvent *)MPCommerceEvent:(id)json; ++ (MPPromotionContainer *)MPPromotionContainer:(id)json; + (MPPromotion *)MPPromotion:(id)json; ++ (MPTransactionAttributes *)MPTransactionAttributes:(id)json; ++ (MPProduct *)MPProduct:(id)json; ++ (MPPromotionAction)MPPromotionAction:(NSNumber *)json; ++ (MPCommerceEventAction)MPCommerceEventAction:(NSNumber *)json; ++ (MPIdentityApiRequest *)MPIdentityApiRequest:(id)json; ++ (MPGDPRConsent *)MPGDPRConsent:(id)json; ++ (MPCCPAConsent *)MPCCPAConsent:(id)json; + (MPConsentState *)MPConsentState:(id)json; @end @@ -45,6 +56,16 @@ static BOOL RNMParticleIsEmptyConsentState(MPConsentState *state) return state.gdprConsentState.count == 0 && state.ccpaConsentState == nil; } +// Returns nil for a missing key or an explicit JS `null`, so callers can assign +// straight into a nullable NSString * property without storing NSNull. +static NSString *RNMParticleNullableString(id value) +{ + if (value == nil || value == (id)[NSNull null]) { + return nil; + } + return [value isKindOfClass:[NSString class]] ? value : [value description]; +} + static NSDictionary *RNMParticleStringAttributes(id attributes) { if (![attributes isKindOfClass:[NSDictionary class]]) { @@ -766,7 +787,10 @@ - (void)getDeviceConsentState:(RCTResponseSenderBlock)callback { RCT_EXPORT_METHOD(logCommerceEvent:(MPCommerceEvent *)commerceEvent) { - [[MParticle sharedInstance] logCommerceEvent:commerceEvent]; + // `logCommerceEvent:` was removed from the public MParticle interface in + // mParticle-Apple-SDK 9.0. `logEvent:` takes any MPBaseEvent subclass and + // dispatches commerce events identically. Matches the New Arch path above. + [[MParticle sharedInstance] logEvent:commerceEvent]; } RCT_EXPORT_METHOD(addGDPRConsentState:(MPGDPRConsent *)gdprConsentState purpose:(NSString *)purpose) @@ -1043,8 +1067,24 @@ + (NSDictionary *)consentStateToDictionary:(MPConsentState *)consentState @end -// RCTConvert category methods for mParticle types -@implementation RCTConvert (MParticle) +typedef NS_ENUM(NSUInteger, MPReactCommerceEventAction) { + MPReactCommerceEventActionAddToCart = 1, + MPReactCommerceEventActionRemoveFromCart, + MPReactCommerceEventActionCheckout, + MPReactCommerceEventActionCheckoutOptions, + MPReactCommerceEventActionClick, + MPReactCommerceEventActionViewDetail, + MPReactCommerceEventActionPurchase, + MPReactCommerceEventActionRefund, + MPReactCommerceEventActionAddToWishList, + MPReactCommerceEventActionRemoveFromWishlist +}; + +// JS -> native converters used by both architectures. Keep this as the ONE +// RCTConvert category in this file: two parallel categories used to implement +// the same selectors here, and which implementation won was undefined +// behaviour (see the `RCTConvert (RNMParticle)` interface above). +@implementation RCTConvert (RNMParticle) + (MPEvent *)MPEvent:(NSDictionary *)dict { MPEvent *event = [[MPEvent alloc] initWithName:dict[@"name"] type:(MPEventType)[dict[@"type"] integerValue]]; @@ -1084,115 +1124,6 @@ + (MPEvent *)MPEvent:(NSDictionary *)dict { return event; } -+ (MPCommerceEvent *)MPCommerceEvent:(NSDictionary *)dict { - MPCommerceEvent *commerceEvent = [[MPCommerceEvent alloc] init]; - - if (dict[@"productActionType"] && dict[@"productActionType"] != [NSNull null]) { - commerceEvent.action = [RCTConvert MPCommerceEventAction:dict[@"productActionType"]]; - } - - if (dict[@"products"] && dict[@"products"] != [NSNull null]) { - NSArray *productDicts = dict[@"products"]; - NSMutableArray *products = [[NSMutableArray alloc] init]; - for (NSDictionary *productDict in productDicts) { - MPProduct *product = [[MPProduct alloc] initWithName:productDict[@"name"] - sku:productDict[@"sku"] - quantity:productDict[@"quantity"] - price:productDict[@"price"]]; - NSDictionary *customAttributes = - RNMParticleStringAttributes(productDict[@"customAttributes"]); - for (NSString *key in customAttributes) { - [product setObject:customAttributes[key] forKeyedSubscript:key]; - } - [products addObject:product]; - } - [commerceEvent addProducts:products]; - } - - if (dict[@"transactionAttributes"] && dict[@"transactionAttributes"] != [NSNull null]) { - NSDictionary *transactionDict = dict[@"transactionAttributes"]; - MPTransactionAttributes *transactionAttributes = [[MPTransactionAttributes alloc] init]; - if (transactionDict[@"transactionId"]) { - transactionAttributes.transactionId = transactionDict[@"transactionId"]; - } - if (transactionDict[@"revenue"]) { - transactionAttributes.revenue = transactionDict[@"revenue"]; - } - if (transactionDict[@"tax"]) { - transactionAttributes.tax = transactionDict[@"tax"]; - } - if (transactionDict[@"shipping"]) { - transactionAttributes.shipping = transactionDict[@"shipping"]; - } - if (transactionDict[@"couponCode"]) { - transactionAttributes.couponCode = transactionDict[@"couponCode"]; - } - if (transactionDict[@"affiliation"]) { - transactionAttributes.affiliation = transactionDict[@"affiliation"]; - } - commerceEvent.transactionAttributes = transactionAttributes; - } - - if (dict[@"customAttributes"] && dict[@"customAttributes"] != [NSNull null]) { - commerceEvent.customAttributes = - RNMParticleEventAttributes(dict[@"customAttributes"]); - } - - if (dict[@"shouldUploadEvent"] && dict[@"shouldUploadEvent"] != [NSNull null]) { - commerceEvent.shouldUploadEvent = [dict[@"shouldUploadEvent"] boolValue]; - } - - return commerceEvent; -} - -+ (MPGDPRConsent *)MPGDPRConsent:(NSDictionary *)dict { - BOOL consented = [dict[@"consented"] boolValue]; - MPGDPRConsent *consent = [[MPGDPRConsent alloc] init]; - consent.consented = consented; - - if (dict[@"document"] && dict[@"document"] != [NSNull null]) { - consent.document = dict[@"document"]; - } - - if (dict[@"timestamp"] && dict[@"timestamp"] != [NSNull null]) { - consent.timestamp = [NSDate dateWithTimeIntervalSince1970:[dict[@"timestamp"] doubleValue] / 1000.0]; - } - - if (dict[@"location"] && dict[@"location"] != [NSNull null]) { - consent.location = dict[@"location"]; - } - - if (dict[@"hardwareId"] && dict[@"hardwareId"] != [NSNull null]) { - consent.hardwareId = dict[@"hardwareId"]; - } - - return consent; -} - -+ (MPCCPAConsent *)MPCCPAConsent:(NSDictionary *)dict { - BOOL consented = [dict[@"consented"] boolValue]; - MPCCPAConsent *consent = [[MPCCPAConsent alloc] init]; - consent.consented = consented; - - if (dict[@"document"] && dict[@"document"] != [NSNull null]) { - consent.document = dict[@"document"]; - } - - if (dict[@"timestamp"] && dict[@"timestamp"] != [NSNull null]) { - consent.timestamp = [NSDate dateWithTimeIntervalSince1970:[dict[@"timestamp"] doubleValue] / 1000.0]; - } - - if (dict[@"location"] && dict[@"location"] != [NSNull null]) { - consent.location = dict[@"location"]; - } - - if (dict[@"hardwareId"] && dict[@"hardwareId"] != [NSNull null]) { - consent.hardwareId = dict[@"hardwareId"]; - } - - return consent; -} - + (MPAliasRequest *)MPAliasRequest:(NSDictionary *)dict { NSString *sourceMpidString = dict[@"sourceMpid"]; NSString *destinationMpidString = dict[@"destinationMpid"]; @@ -1213,43 +1144,6 @@ + (MPAliasRequest *)MPAliasRequest:(NSDictionary *)dict { return [MPAliasRequest requestWithSourceMPID:sourceMpid destinationMPID:destinationMpid startTime:startTime endTime:endTime]; } -@end - -typedef NS_ENUM(NSUInteger, MPReactCommerceEventAction) { - MPReactCommerceEventActionAddToCart = 1, - MPReactCommerceEventActionRemoveFromCart, - MPReactCommerceEventActionCheckout, - MPReactCommerceEventActionCheckoutOptions, - MPReactCommerceEventActionClick, - MPReactCommerceEventActionViewDetail, - MPReactCommerceEventActionPurchase, - MPReactCommerceEventActionRefund, - MPReactCommerceEventActionAddToWishList, - MPReactCommerceEventActionRemoveFromWishlist -}; - -@interface RCTConvert (MPCommerceEvent) - -+ (MPCommerceEvent *)MPCommerceEvent:(id)json; -+ (MPPromotionContainer *)MPPromotionContainer:(id)json; -+ (MPPromotion *)MPPromotion:(id)json; -+ (MPTransactionAttributes *)MPTransactionAttributes:(id)json; -+ (MPProduct *)MPProduct:(id)json; -+ (MPCommerceEventAction)MPCommerceEventAction:(id)json; -+ (MPPromotionAction)MPPromotionAction:(id)json; -+ (MPIdentityApiRequest *)MPIdentityApiRequest:(id)json; -+ (MPIdentityApiResult *)MPIdentityApiResult:(id)json; -+ (MPAliasRequest *)MPAliasRequest:(id)json; -+ (MParticleUser *)MParticleUser:(id)json; -+ (MPEvent *)MPEvent:(id)json; -+ (MPGDPRConsent *)MPGDPRConsent:(id)json; -+ (MPCCPAConsent *)MPCCPAConsent:(id)json; -+ (MPConsentState *)MPConsentState:(id)json; - -@end - -@implementation RCTConvert (MPCommerceEvent) - + (MPCommerceEvent *)MPCommerceEvent:(id)json { BOOL isProductAction = json[@"productActionType"] != nil; BOOL isPromotion = json[@"promotionActionType"] != nil; @@ -1270,11 +1164,13 @@ + (MPCommerceEvent *)MPCommerceEvent:(id)json { commerceEvent = [[MPCommerceEvent alloc] initWithImpressionName:nil product:nil]; } - commerceEvent.checkoutOptions = json[@"checkoutOptions"]; - commerceEvent.currency = json[@"currency"]; - commerceEvent.productListName = json[@"productActionListName"]; - commerceEvent.productListSource = json[@"productActionListSource"]; - commerceEvent.screenName = json[@"screenName"]; + // Explicit JS `null` is treated as absent, never assigned into the + // NSString * properties. Matches the Android bridge's `?.let` handling. + commerceEvent.checkoutOptions = RNMParticleNullableString(json[@"checkoutOptions"]); + commerceEvent.currency = RNMParticleNullableString(json[@"currency"]); + commerceEvent.productListName = RNMParticleNullableString(json[@"productActionListName"]); + commerceEvent.productListSource = RNMParticleNullableString(json[@"productActionListSource"]); + commerceEvent.screenName = RNMParticleNullableString(json[@"screenName"]); commerceEvent.transactionAttributes = [RCTConvert MPTransactionAttributes:json[@"transactionAttributes"]]; commerceEvent.checkoutStep = [json[@"checkoutStep"] intValue]; commerceEvent.nonInteractive = [json[@"nonInteractive"] boolValue]; @@ -1425,7 +1321,6 @@ + (MPCommerceEventAction)MPCommerceEventAction:(NSNumber *)json { return action; } - + (MPIdentityApiRequest *)MPIdentityApiRequest:(id)json { NSDictionary *dict = json; MPIdentityApiRequest *request = [MPIdentityApiRequest requestWithEmptyUser]; @@ -1449,77 +1344,16 @@ + (MPIdentityApiRequest *)MPIdentityApiRequest:(id)json { return request; } - - -+ (MPIdentityApiResult *)MPIdentityApiResult:(id)json { - MPIdentityApiResult *result = [[MPIdentityApiResult alloc] init]; - id obj = json[@"user"]; - result.user = [RCTConvert MParticleUser:obj]; - - return result; -} - -+ (MPAliasRequest *)MPAliasRequest:(id)json { - NSString *destinationMpidString = json[@"destinationMpid"]; - NSString *sourceMpidString = json[@"sourceMpid"]; - NSString *startTime = json[@"startTime"]; - NSString *endTime = json[@"endTime"]; - NSNumber *destinationMpid = [NSNumber numberWithLong:destinationMpidString.longLongValue]; - NSNumber *sourceMpid = [NSNumber numberWithLong:sourceMpidString.longLongValue]; - NSDate *startDate = nil; - NSDate *endDate = nil; - - if (startTime != nil && startTime != [NSNull null]) { - startDate = [NSDate dateWithTimeIntervalSince1970:startTime.longLongValue]; - } - - if (endTime != nil && endTime != [NSNull null]) { - endDate = [NSDate dateWithTimeIntervalSince1970:endTime.longLongValue]; - } - - return [MPAliasRequest requestWithSourceMPID:sourceMpid destinationMPID:destinationMpid startTime:startDate endTime:endDate]; -} - -+ (MParticleUser *)MParticleUser:(id)json { - MParticleUser *user = [[MParticleUser alloc] init]; - user.userId = json[@"userId"]; - - return user; -} - -+ (MPEvent *)MPEvent:(id)json { - MPEvent *event = [[MPEvent alloc] init]; - - event.category = json[@"category"]; - event.duration = json[@"duration"]; - event.endTime = json[@"endTime"]; - event.customAttributes = RNMParticleEventAttributes(json[@"info"]); - event.name = json[@"name"]; - event.startTime = json[@"startTime"]; - [event setType:(MPEventType)[json[@"type"] intValue]]; - if (json[@"shouldUploadEvent"] != nil) { - event.shouldUploadEvent = [json[@"shouldUploadEvent"] boolValue]; - } - - NSDictionary *jsonFlags = json[@"customFlags"]; - for (NSString *key in jsonFlags) { - NSString *value = jsonFlags[key]; - [event addCustomFlag:value withKey:key]; - } - - return event; -} - + (MPGDPRConsent *)MPGDPRConsent:(id)json { MPGDPRConsent *mpConsent = [[MPGDPRConsent alloc] init]; mpConsent.consented = [RCTConvert BOOL:json[@"consented"]]; - mpConsent.document = json[@"document"]; + mpConsent.document = RNMParticleNullableString(json[@"document"]); if (json[@"timestamp"] && json[@"timestamp"] != [NSNull null]) { mpConsent.timestamp = [NSDate dateWithTimeIntervalSince1970:[json[@"timestamp"] doubleValue] / 1000.0]; } - mpConsent.location = json[@"location"]; - mpConsent.hardwareId = json[@"hardwareId"]; + mpConsent.location = RNMParticleNullableString(json[@"location"]); + mpConsent.hardwareId = RNMParticleNullableString(json[@"hardwareId"]); return mpConsent; } @@ -1528,12 +1362,12 @@ + (MPCCPAConsent *)MPCCPAConsent:(id)json { MPCCPAConsent *mpConsent = [[MPCCPAConsent alloc] init]; mpConsent.consented = [RCTConvert BOOL:json[@"consented"]]; - mpConsent.document = json[@"document"]; + mpConsent.document = RNMParticleNullableString(json[@"document"]); if (json[@"timestamp"] && json[@"timestamp"] != [NSNull null]) { mpConsent.timestamp = [NSDate dateWithTimeIntervalSince1970:[json[@"timestamp"] doubleValue] / 1000.0]; } - mpConsent.location = json[@"location"]; - mpConsent.hardwareId = json[@"hardwareId"]; + mpConsent.location = RNMParticleNullableString(json[@"location"]); + mpConsent.hardwareId = RNMParticleNullableString(json[@"hardwareId"]); return mpConsent; } diff --git a/react-native-mparticle.podspec b/react-native-mparticle.podspec index 6bf2d47..8e6d6c4 100644 --- a/react-native-mparticle.podspec +++ b/react-native-mparticle.podspec @@ -1,6 +1,5 @@ require 'json' -new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1' ios_platform = '15.6' package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) diff --git a/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m b/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m index 52a2e6d..accab77 100644 --- a/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m +++ b/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m @@ -12,12 +12,16 @@ #endif // Implemented on `RCTConvert` in `RNMParticle.mm` (react-native-mparticle pod). -@interface RCTConvert (MPCommerceEvent) +// Parameter types mirror the implementations there. +@interface RCTConvert (RNMParticle) + (MPCommerceEvent *)MPCommerceEvent:(id)json; -+ (MPCommerceEventAction)MPCommerceEventAction:(id)json; -+ (MPPromotionAction)MPPromotionAction:(id)json; ++ (MPCommerceEventAction)MPCommerceEventAction:(NSNumber *)json; ++ (MPPromotionAction)MPPromotionAction:(NSNumber *)json; + (MPProduct *)MPProduct:(id)json; -+ (MPEvent *)MPEvent:(id)json; ++ (MPEvent *)MPEvent:(NSDictionary *)dict; ++ (MPAliasRequest *)MPAliasRequest:(NSDictionary *)dict; ++ (MPGDPRConsent *)MPGDPRConsent:(id)json; ++ (MPCCPAConsent *)MPCCPAConsent:(id)json; @end @interface RNMParticle (ProductMappingTests) @@ -262,4 +266,168 @@ - (void)testAddPromotionsFromDicts_fillsPromotionContainerForNewArchitecture XCTAssertEqualObjects(promotion.position, @"home-top"); } +#pragma mark - Timestamps and explicit nulls + +/** + * `RNMParticle.mm` used to carry two `RCTConvert` categories implementing these + * same selectors, and which one the runtime picked was undefined. The copy that + * won assigned raw JS numbers into `NSDate *` properties and read alias times as + * seconds rather than milliseconds. These tests pin the surviving semantics so a + * reintroduced duplicate fails here instead of silently in production. + * + * The JS contract is epoch milliseconds throughout (`js/codegenSpecs/NativeMParticle.ts`, + * and the Android bridge passes the same values through unscaled), so every + * conversion into an Apple `NSDate` divides by 1000. + */ + +- (void)testMPEventFromJSON_convertsMillisecondTimesToDates +{ + MPEvent *event = [RCTConvert MPEvent:@{ + @"name" : @"Timed Event", + @"type" : @(8), + @"startTime" : @1700000000000, + @"endTime" : @1700000005000, + @"duration" : @5000, + @"category" : @"checkout", + }]; + + XCTAssertTrue([event.startTime isKindOfClass:[NSDate class]]); + XCTAssertTrue([event.endTime isKindOfClass:[NSDate class]]); + XCTAssertEqualWithAccuracy(event.startTime.timeIntervalSince1970, 1700000000.0, 0.001); + XCTAssertEqualWithAccuracy(event.endTime.timeIntervalSince1970, 1700000005.0, 0.001); + // MPEvent.duration is milliseconds on both platforms - pass through, no scaling. + XCTAssertEqualObjects(event.duration, @5000); + XCTAssertEqualObjects(event.category, @"checkout"); +} + +- (void)testMPEventFromJSON_treatsExplicitNullAsAbsent +{ + MPEvent *event = [RCTConvert MPEvent:@{ + @"name" : @"Untimed Event", + @"type" : @(8), + @"startTime" : [NSNull null], + @"endTime" : [NSNull null], + @"duration" : [NSNull null], + @"category" : [NSNull null], + }]; + + XCTAssertNil(event.startTime); + XCTAssertNil(event.endTime); + XCTAssertNil(event.category); + // MPEvent's initializer seeds duration to @0, so the converter leaving it + // alone is correct; what matters is that NSNull never reaches the property. + XCTAssertEqualObjects(event.duration, @0); +} + +- (void)testMPAliasRequestFromJSON_convertsMillisecondTimesToDates +{ + MPAliasRequest *request = [RCTConvert MPAliasRequest:@{ + @"sourceMpid" : @"123", + @"destinationMpid" : @"456", + @"startTime" : @1700000000000, + @"endTime" : @1700000005000, + }]; + + XCTAssertEqualObjects(request.sourceMPID, @123); + XCTAssertEqualObjects(request.destinationMPID, @456); + // Regression: the shadowed converter treated these milliseconds as seconds, + // producing dates ~1000x in the future. + XCTAssertEqualWithAccuracy(request.startTime.timeIntervalSince1970, 1700000000.0, 0.001); + XCTAssertEqualWithAccuracy(request.endTime.timeIntervalSince1970, 1700000005.0, 0.001); +} + +- (void)testMPAliasRequestFromJSON_treatsExplicitNullAsAbsent +{ + MPAliasRequest *request = [RCTConvert MPAliasRequest:@{ + @"sourceMpid" : @"123", + @"destinationMpid" : @"456", + @"startTime" : [NSNull null], + @"endTime" : [NSNull null], + }]; + + XCTAssertNil(request.startTime); + XCTAssertNil(request.endTime); +} + +- (void)testGDPRConsentFromJSON_convertsMillisecondTimestampAndTreatsNullAsAbsent +{ + MPGDPRConsent *consent = [RCTConvert MPGDPRConsent:@{ + @"consented" : @YES, + @"timestamp" : @1700000000000, + @"document" : @"terms-v3", + @"location" : [NSNull null], + @"hardwareId" : [NSNull null], + }]; + + XCTAssertTrue(consent.consented); + XCTAssertEqualWithAccuracy(consent.timestamp.timeIntervalSince1970, 1700000000.0, 0.001); + XCTAssertEqualObjects(consent.document, @"terms-v3"); + XCTAssertNil(consent.location); + XCTAssertNil(consent.hardwareId); +} + +- (void)testCCPAConsentFromJSON_convertsMillisecondTimestampAndTreatsNullAsAbsent +{ + MPCCPAConsent *consent = [RCTConvert MPCCPAConsent:@{ + @"consented" : @NO, + @"timestamp" : @1700000000000, + @"document" : [NSNull null], + @"location" : @"https://example.com/privacy", + @"hardwareId" : [NSNull null], + }]; + + XCTAssertFalse(consent.consented); + XCTAssertEqualWithAccuracy(consent.timestamp.timeIntervalSince1970, 1700000000.0, 0.001); + XCTAssertNil(consent.document); + XCTAssertEqualObjects(consent.location, @"https://example.com/privacy"); + XCTAssertNil(consent.hardwareId); +} + +- (void)testMPCommerceEventFromJSON_treatsExplicitNullStringsAsAbsent +{ + MPCommerceEvent *event = [RCTConvert MPCommerceEvent:@{ + @"productActionType" : @(7), + @"products" : @[ [self minimalProductJSON] ], + @"impressions" : @[], + @"currency" : [NSNull null], + @"checkoutOptions" : [NSNull null], + @"productActionListName" : [NSNull null], + @"productActionListSource" : [NSNull null], + @"screenName" : [NSNull null], + }]; + + XCTAssertNil(event.currency); + XCTAssertNil(event.checkoutOptions); + XCTAssertNil(event.productListName); + XCTAssertNil(event.productListSource); + XCTAssertNil(event.screenName); +} + +#pragma mark - Commerce logging API + +/** + * `-[MParticle logCommerceEvent:]` was removed from the public headers in + * mParticle-Apple-SDK 9.0 while its implementation stayed behind, which broke the + * legacy-architecture bridge. `logEvent:` is the replacement and accepts any + * MPBaseEvent subclass. + * + * The guard is the `logEvent:` call below, not an assertion: that failure mode is + * declaration removed / implementation retained, so it is invisible at runtime. + * `respondsToSelector:` and `@selector()` both still succeed against a selector + * whose declaration is gone -- only compiling a real message send requires a + * visible declaration. So if a future SDK drops `logEvent:` from its headers this + * test target stops building, which is exactly how the original break surfaced. + * + * `shouldUploadEvent = NO` keeps the call compiled and executed without uploading + * to the sample app's workspace. + */ +- (void)testCommerceEventsAreLoggableThroughLogEvent +{ + MPCommerceEvent *event = [[MPCommerceEvent alloc] initWithAction:MPCommerceEventActionPurchase]; + event.shouldUploadEvent = NO; + + XCTAssertTrue([event isKindOfClass:[MPBaseEvent class]]); + [[MParticle sharedInstance] logEvent:event]; +} + @end