From a38ed8be4d86c2767101a0148f9bc0a0343f3594 Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Tue, 28 Jul 2026 11:44:05 -0400 Subject: [PATCH] fix(commerce): log events without transaction attributes Always build Android product-action events before optionally attaching transaction attributes, so AddToCart and similar events are not silently dropped. Omit the JavaScript factory synthetic empty transaction ID and add cross-layer regression coverage for omitted and explicit attributes. #agentic --- README.md | 10 +- .../com/mparticle/react/MParticleModule.kt | 20 ++- .../mparticle/react/MParticleModuleTest.java | 123 ++++++++++++++++++ js/__tests__/attribute-normalization.test.ts | 36 +++++ js/index.tsx | 11 +- 5 files changed, 180 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5c800261..db06909a 100644 --- a/README.md +++ b/README.md @@ -386,18 +386,18 @@ To log commerce events: ```js const product = new MParticle.Product('Test product for cart', '1234', 19.99); -const transactionAttributes = new MParticle.TransactionAttributes( - 'Test transaction id' -); const event = MParticle.CommerceEvent.createProductActionEvent( MParticle.ProductActionType.AddToCart, - [product], - transactionAttributes + [product] ); MParticle.logCommerceEvent(event); ``` +Transaction attributes are optional for product actions such as AddToCart. +Purchase and Refund events must include `TransactionAttributes` with a unique +transaction ID. + ```js const promotion = new MParticle.Promotion( 'Test promotion id', diff --git a/android/src/main/java/com/mparticle/react/MParticleModule.kt b/android/src/main/java/com/mparticle/react/MParticleModule.kt index 70877cad..92a56a91 100644 --- a/android/src/main/java/com/mparticle/react/MParticleModule.kt +++ b/android/src/main/java/com/mparticle/react/MParticleModule.kt @@ -645,16 +645,14 @@ class MParticleModule( val product = convertProduct(productMap) ?: return null val transactionAttributesMap = map.getMap("transactionAttributes") val transactionAttributes = convertTransactionAttributes(transactionAttributesMap) - val builder = - transactionAttributes?.let { - CommerceEvent.Builder(productAction, product).transactionAttributes(it) - } + val builder = CommerceEvent.Builder(productAction, product) + transactionAttributes?.let { builder.transactionAttributes(it) } for (i in 1 until productsArray.size()) { val nextProductMap = productsArray.getMap(i) val nextProduct = convertProduct(nextProductMap) if (nextProduct != null) { - builder?.addProduct(nextProduct) + builder.addProduct(nextProduct) } } builder @@ -696,22 +694,22 @@ class MParticleModule( } if (map.hasKey("shouldUploadEvent")) { - builder?.shouldUploadEvent(map.getBoolean("shouldUploadEvent")) + builder.shouldUploadEvent(map.getBoolean("shouldUploadEvent")) } if (map.hasKey("customAttributes")) { - builder?.customAttributes(convertStringMap(map.getMap("customAttributes"))) + builder.customAttributes(convertStringMap(map.getMap("customAttributes"))) } if (map.hasKey("currency")) { - map.getString("currency")?.let { builder?.currency(it) } + map.getString("currency")?.let { builder.currency(it) } } if (map.hasKey("checkoutStep")) { - builder?.checkoutStep(map.getInt("checkoutStep")) + builder.checkoutStep(map.getInt("checkoutStep")) } if (map.hasKey("checkoutOptions")) { - map.getString("checkoutOptions")?.let { builder?.checkoutOptions(it) } + map.getString("checkoutOptions")?.let { builder.checkoutOptions(it) } } - return builder?.build() + return builder.build() } private fun convertProduct(map: ReadableMap?): Product? { diff --git a/android/src/test/java/com/mparticle/react/MParticleModuleTest.java b/android/src/test/java/com/mparticle/react/MParticleModuleTest.java index 1231db55..905e8f56 100644 --- a/android/src/test/java/com/mparticle/react/MParticleModuleTest.java +++ b/android/src/test/java/com/mparticle/react/MParticleModuleTest.java @@ -1,8 +1,12 @@ package com.mparticle.react; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReactApplicationContext; import com.mparticle.BaseEvent; import com.mparticle.MParticle; +import com.mparticle.commerce.CommerceEvent; +import com.mparticle.commerce.Product; import com.mparticle.react.testutils.MockMap; import org.junit.Before; @@ -16,9 +20,14 @@ import java.util.Map; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; @RunWith(MockitoJUnitRunner.class) public class MParticleModuleTest { + private static final int ADD_TO_CART_ACTION = 1; + private static final int PURCHASE_ACTION = 7; + private MParticle mParticle; private MParticleModule module; @@ -40,4 +49,118 @@ public void logEventConvertsNullCustomAttributeToEmptyString() { Mockito.verify(mParticle).logEvent(eventCaptor.capture()); assertEquals("", eventCaptor.getValue().getCustomAttributes().get("coupon_code")); } + + @Test + public void logCommerceEventLogsAddToCartWithoutTransactionAttributes() { + ReadableArray products = products( + product("Shirt", "shirt-1", 25.0), + product("Shoes", "shoes-1", 50.0)); + + module.logCommerceEvent(commerceEvent(ADD_TO_CART_ACTION, products, null)); + + CommerceEvent event = captureCommerceEvent(); + assertEquals(Product.ADD_TO_CART, event.getProductAction()); + assertNotNull(event.getProducts()); + assertEquals(2, event.getProducts().size()); + assertNotNull(event.getTransactionAttributes()); + assertNull(event.getTransactionAttributes().getId()); + } + + @Test + public void logCommerceEventLogsAddToCartWithEmptyTransactionAttributes() { + ReadableMap transactionAttributes = Mockito.mock(ReadableMap.class); + + module.logCommerceEvent(commerceEvent( + ADD_TO_CART_ACTION, + products(product("Shirt", "shirt-1", 25.0)), + transactionAttributes)); + + CommerceEvent event = captureCommerceEvent(); + assertEquals(Product.ADD_TO_CART, event.getProductAction()); + assertNotNull(event.getTransactionAttributes()); + assertNull(event.getTransactionAttributes().getId()); + } + + @Test + public void logCommerceEventLogsAddToCartWithEmptyTransactionId() { + ReadableMap transactionAttributes = transactionAttributes(""); + + module.logCommerceEvent(commerceEvent( + ADD_TO_CART_ACTION, + products(product("Shirt", "shirt-1", 25.0)), + transactionAttributes)); + + CommerceEvent event = captureCommerceEvent(); + assertEquals(Product.ADD_TO_CART, event.getProductAction()); + assertEquals("", event.getTransactionAttributes().getId()); + } + + @Test + public void logCommerceEventAttachesValidPurchaseTransactionAttributes() { + ReadableMap transactionAttributes = transactionAttributes("order-123"); + + module.logCommerceEvent(commerceEvent( + PURCHASE_ACTION, + products(product("Shirt", "shirt-1", 25.0)), + transactionAttributes)); + + CommerceEvent event = captureCommerceEvent(); + assertEquals(Product.PURCHASE, event.getProductAction()); + assertEquals("order-123", event.getTransactionAttributes().getId()); + } + + @Test + public void logCommerceEventPassesPurchaseWithoutTransactionAttributesToNativeSdk() { + module.logCommerceEvent(commerceEvent( + PURCHASE_ACTION, + products(product("Shirt", "shirt-1", 25.0)), + null)); + + CommerceEvent event = captureCommerceEvent(); + assertEquals(Product.PURCHASE, event.getProductAction()); + assertNotNull(event.getTransactionAttributes()); + assertNull(event.getTransactionAttributes().getId()); + } + + private CommerceEvent captureCommerceEvent() { + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(BaseEvent.class); + Mockito.verify(mParticle).logEvent(eventCaptor.capture()); + return (CommerceEvent) eventCaptor.getValue(); + } + + private ReadableMap commerceEvent( + int productActionType, + ReadableArray products, + ReadableMap transactionAttributes) { + ReadableMap event = Mockito.mock(ReadableMap.class); + Mockito.when(event.hasKey("productActionType")).thenReturn(true); + Mockito.when(event.getInt("productActionType")).thenReturn(productActionType); + Mockito.when(event.getArray("products")).thenReturn(products); + Mockito.when(event.getMap("transactionAttributes")).thenReturn(transactionAttributes); + return event; + } + + private ReadableArray products(ReadableMap... products) { + ReadableArray productArray = Mockito.mock(ReadableArray.class); + Mockito.when(productArray.size()).thenReturn(products.length); + for (int i = 0; i < products.length; i++) { + Mockito.when(productArray.getMap(i)).thenReturn(products[i]); + } + return productArray; + } + + private ReadableMap product(String name, String sku, double price) { + ReadableMap product = Mockito.mock(ReadableMap.class); + Mockito.when(product.getString("name")).thenReturn(name); + Mockito.when(product.getString("sku")).thenReturn(sku); + Mockito.when(product.getDouble("price")).thenReturn(price); + return product; + } + + private ReadableMap transactionAttributes(String transactionId) { + ReadableMap transactionAttributes = Mockito.mock(ReadableMap.class); + Mockito.when(transactionAttributes.hasKey("transactionId")).thenReturn(true); + Mockito.when(transactionAttributes.getString("transactionId")).thenReturn(transactionId); + return transactionAttributes; + } } diff --git a/js/__tests__/attribute-normalization.test.ts b/js/__tests__/attribute-normalization.test.ts index e5ad843d..3d624814 100644 --- a/js/__tests__/attribute-normalization.test.ts +++ b/js/__tests__/attribute-normalization.test.ts @@ -33,6 +33,7 @@ import { Event, Impression, Product, + TransactionAttributes, logCommerceEvent, logEvent, logMPEvent, @@ -167,4 +168,39 @@ describe('custom attribute normalization', () => { expect(normalizedCommerce.impressions).toBeUndefined(); expect(normalizedCommerce.products[0].customAttributes).toBeUndefined(); }); + + it('omits product action transaction attributes when none are provided', () => { + const event = CommerceEvent.createProductActionEvent(1, [ + new Product('Shirt', 'shirt-1', 25), + ]); + + expect(event.transactionAttributes).toBeUndefined(); + + logCommerceEvent(event); + + const normalizedEvent = mockNativeModule.logCommerceEvent.mock.calls[0][0]; + expect( + Object.prototype.hasOwnProperty.call( + normalizedEvent, + 'transactionAttributes' + ) + ).toBe(false); + }); + + it('preserves explicitly provided product action transaction attributes', () => { + const transactionAttributes = new TransactionAttributes('order-123'); + const event = CommerceEvent.createProductActionEvent( + 7, + [new Product('Shirt', 'shirt-1', 25)], + transactionAttributes + ); + + expect(event.transactionAttributes).toBe(transactionAttributes); + + logCommerceEvent(event); + + expect( + mockNativeModule.logCommerceEvent.mock.calls[0][0].transactionAttributes + ).toBe(transactionAttributes); + }); }); diff --git a/js/index.tsx b/js/index.tsx index 092b70d8..fd14a7e5 100644 --- a/js/index.tsx +++ b/js/index.tsx @@ -799,12 +799,15 @@ export class CommerceEvent { static createProductActionEvent( productActionType: number, products: Product[], - transactionAttributes: TransactionAttributes = new TransactionAttributes('') + transactionAttributes?: TransactionAttributes ): CommerceEvent { - return new CommerceEvent() + const commerceEvent = new CommerceEvent() .setProductActionType(productActionType) - .setProducts(products) - .setTransactionAttributes(transactionAttributes); + .setProducts(products); + + return transactionAttributes == null + ? commerceEvent + : commerceEvent.setTransactionAttributes(transactionAttributes); } static createPromotionEvent(