From 0bda20293492f6790e16a89a04b6c2ef47113671 Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Thu, 17 Sep 2026 22:52:11 -0400 Subject: [PATCH 1/2] fix(ios): restore legacy-architecture build and collapse duplicate RCTConvert categories Building with RCT_NEW_ARCH_ENABLED=0 (React Native <= 0.81) failed to compile: RNMParticle.mm:769:33 No visible @interface for 'MParticle' declares the selector 'logCommerceEvent:' -[MParticle logCommerceEvent:] was deprecated in mParticle-Apple-SDK 7.12.6 with an explicit "call logEvent: instead" message, and its declaration was removed from the public headers in 9.0.0 while the implementation stayed behind as an internal entry point. When the podspec moved to mParticle-Apple-SDK-ObjC ~> 9.0, the New Architecture leg was migrated to logEvent: but the legacy leg was not. Nothing caught it: the only iOS build in CI targets sample/, which is on React Native 0.84, and React Native removed the legacy architecture in 0.82 -- so every #else leg under ios/ has zero compiler coverage. This aligns iOS with Android, where logEvent(BaseEvent) is the sole public entry point and logCommerceEvent is private. Investigating that blind spot surfaced a second defect on the same path. Two RCTConvert categories implemented five identical selectors with different bodies, and which one the Objective-C runtime picked was undefined behaviour. They are now collapsed into a single RCTConvert (RNMParticle) category, keeping per selector the implementation that is correct rather than the one that happened to win: - +MPEvent: kept the copy that converts startTime/endTime from JS milliseconds into NSDate. The copy that was running assigned the raw NSNumber straight into the NSDate * properties. - +MPAliasRequest: kept the copy that treats startTime/endTime as milliseconds. The copy that was running read them as seconds, producing dates roughly 1000x in the future. Milliseconds matches the Android SDK ("the time, in milliseconds"), the Android bridge, the codegen spec, and the New Architecture path, which already divided by 1000. - +MPCommerceEvent: kept the copy that maps promotions, impressions, currency, checkout fields, productList* and screenName. The other mapped none of them. - +MPGDPRConsent:/+MPCCPAConsent: kept the millisecond timestamp handling added deliberately for device consent, and added the other copy's null handling for document/location/hardwareId. Explicit JS null is now treated as absent rather than stored as NSNull for those consent fields and for the commerce string fields, matching the Android bridge. Also removes two converters dead in both architectures, the duplicate category interface, the declared-vs-implemented parameter type mismatches, and a podspec variable with no reader since the SDK 9 bump. Tests cover the timestamp conversions and null handling for MPEvent, MPAliasRequest and both consent types -- none of which had any coverage before, which is why the conflicts went unnoticed -- plus a guard that commerce events remain loggable through logEvent:. Verified by reproducing the reported failure in a React Native 0.81.6 app with RCT_NEW_ARCH_ENABLED=0 and confirming it builds cleanly afterwards, the first time the whole iOS legacy leg has been compiled. The New Architecture suite passes 18/18 on React Native 0.84. Co-Authored-By: Claude Opus 5 (1M context) --- ios/RNMParticle/RNMParticle.mm | 288 ++++-------------- react-native-mparticle.podspec | 1 - .../RCTConvertCommerceMappingTests.m | 163 +++++++++- 3 files changed, 220 insertions(+), 232 deletions(-) diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index cda423ae..da150776 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 6bf2d47b..8e6d6c4f 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 52a2e6d9..036dd8a8 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,155 @@ - (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. Fails loudly if a future SDK bump moves that too. + */ +- (void)testCommerceEventsAreLoggableThroughLogEvent +{ + XCTAssertTrue([MPCommerceEvent isSubclassOfClass:[MPBaseEvent class]]); + XCTAssertTrue([[MParticle sharedInstance] respondsToSelector:@selector(logEvent:)]); +} + @end From 3a6b75721836e3bf694c77976f559efd78679fd7 Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Fri, 18 Sep 2026 11:02:22 -0400 Subject: [PATCH 2/2] test(ios): guard logEvent: with a compiled call instead of a runtime assertion The previous guard asserted respondsToSelector:@selector(logEvent:), which cannot detect the failure mode it was written for. That failure mode is declaration removed, implementation retained: point the same assertion at logCommerceEvent: on main today and it passes while the build is broken, because the implementation is still there (mParticle.m:1169). @selector() is no help either -- logCommerceEvent: is still declared in MPKitProtocol.h, so the expression compiles without even a -Wundeclared-selector warning. Only compiling a real message send requires a visible declaration, so the test now builds and executes the call. shouldUploadEvent = NO keeps it from uploading. Verified by negative control: swapping the call to logCommerceEvent: fails the test target build with "no visible @interface for 'MParticle' declares the selector 'logCommerceEvent:'" -- the same error class as the original break. Restored, suite is 18/18. Co-Authored-By: Claude Opus 5 (1M context) --- .../RCTConvertCommerceMappingTests.m | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m b/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m index 036dd8a8..accab77a 100644 --- a/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m +++ b/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m @@ -409,12 +409,25 @@ - (void)testMPCommerceEventFromJSON_treatsExplicitNullStringsAsAbsent * `-[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. Fails loudly if a future SDK bump moves that too. + * 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 { - XCTAssertTrue([MPCommerceEvent isSubclassOfClass:[MPBaseEvent class]]); - XCTAssertTrue([[MParticle sharedInstance] respondsToSelector:@selector(logEvent:)]); + MPCommerceEvent *event = [[MPCommerceEvent alloc] initWithAction:MPCommerceEventActionPurchase]; + event.shouldUploadEvent = NO; + + XCTAssertTrue([event isKindOfClass:[MPBaseEvent class]]); + [[MParticle sharedInstance] logEvent:event]; } @end