From 5d248e75609f95f7e09b28cbfa662b4cc7377db0 Mon Sep 17 00:00:00 2001 From: NickSxti Date: Tue, 10 Mar 2026 21:03:16 +0400 Subject: [PATCH 1/4] Add DeferredPurchasesListener interface, deprecate EntitlementsUpdateListener Introduces a new DeferredPurchasesListener with onDeferredPurchaseCompleted callback for deferred purchase completions (SCA, Ask to Buy, etc.). - New DeferredPurchasesListener interface and TurboModule event - Adapter pattern: deprecated setEntitlementsUpdateListener wraps to new interface - Config supports both listeners, new takes priority - iOS/Android native bridges emit both events from entitlements delegate - 9 unit tests covering new listener, adapter, config, backward compat DEV-643 Co-Authored-By: Claude Opus 4.6 --- .../reactnativesdk/QonversionModule.kt | 1 + ios/RNQonversion.mm | 1 + src/QonversionApi.ts | 20 ++ src/QonversionConfig.ts | 9 +- src/QonversionConfigBuilder.ts | 24 +- src/dto/DeferredPurchasesListener.ts | 11 + src/dto/EntitlementsUpdateListener.ts | 3 + src/index.ts | 1 + src/internal/QonversionInternal.ts | 29 ++- .../__tests__/QonversionInternal.test.ts | 234 ++++++++++++++++++ src/internal/specs/NativeQonversionModule.ts | 1 + 11 files changed, 322 insertions(+), 12 deletions(-) create mode 100644 src/dto/DeferredPurchasesListener.ts create mode 100644 src/internal/__tests__/QonversionInternal.test.ts diff --git a/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt b/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt index 0565d170..0455b002 100644 --- a/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt +++ b/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt @@ -297,6 +297,7 @@ class QonversionModule(reactContext: ReactApplicationContext) : NativeQonversion override fun onEntitlementsUpdated(entitlements: BridgeData) { val mappedEntitlements = EntitiesConverter.convertMapToWritableMap(entitlements) emitOnEntitlementsUpdated(mappedEntitlements) + emitOnDeferredPurchaseCompleted(mappedEntitlements) } companion object { diff --git a/ios/RNQonversion.mm b/ios/RNQonversion.mm index 2088da9d..c2634417 100644 --- a/ios/RNQonversion.mm +++ b/ios/RNQonversion.mm @@ -312,6 +312,7 @@ - (void)updatePurchase:(nonnull NSString *)productId offerId:(NSString * _Nullab - (void)qonversionDidReceiveUpdatedEntitlements:(NSDictionary * _Nonnull)entitlements { @try { [self emitOnEntitlementsUpdated:entitlements]; + [self emitOnDeferredPurchaseCompleted:entitlements]; } @catch (NSException *exception) { QNR_LOG_EXCEPTION("qonversionDidReceiveUpdatedEntitlements", exception); } diff --git a/src/QonversionApi.ts b/src/QonversionApi.ts index 144cc965..c1ce9312 100644 --- a/src/QonversionApi.ts +++ b/src/QonversionApi.ts @@ -5,6 +5,7 @@ import Offerings from './dto/Offerings'; import IntroEligibility from './dto/IntroEligibility'; import User from './dto/User'; import type {EntitlementsUpdateListener} from './dto/EntitlementsUpdateListener'; +import type {DeferredPurchasesListener} from './dto/DeferredPurchasesListener'; import type {PromoPurchasesListener} from './dto/PromoPurchasesListener'; import RemoteConfig from "./dto/RemoteConfig"; import RemoteConfigList from "./dto/RemoteConfigList"; @@ -239,9 +240,28 @@ export interface QonversionApi { * with {@link Qonversion.initialize}. * * @param listener listener to be called when entitlements update + * @deprecated Use {@link setDeferredPurchasesListener} instead. */ setEntitlementsUpdateListener(listener: EntitlementsUpdateListener): void; + /** + * Provide a listener to be notified about deferred purchase completions. + * + * Deferred purchases happen when transactions require additional steps to complete, + * such as SCA (Strong Customer Authentication), Ask to Buy, or other pending transactions. + * This listener will be called when such purchases are finalized. + * + * Make sure you provide this listener for being up-to-date with deferred purchase completions. + * Also, please, consider that this listener should live for the whole lifetime of the application. + * + * You may set deferred purchases listener both *after* Qonversion SDK initializing + * with {@link QonversionApi.setDeferredPurchasesListener} and *while* Qonversion initializing + * with {@link Qonversion.initialize}. + * + * @param listener listener to be called when a deferred purchase completes + */ + setDeferredPurchasesListener(listener: DeferredPurchasesListener): void; + /** * iOS only. Does nothing if called on Android. * diff --git a/src/QonversionConfig.ts b/src/QonversionConfig.ts index 3509b142..a326d1a4 100644 --- a/src/QonversionConfig.ts +++ b/src/QonversionConfig.ts @@ -1,14 +1,17 @@ import {EntitlementsCacheLifetime, Environment, LaunchMode} from './dto/enums'; import type {EntitlementsUpdateListener} from './dto/EntitlementsUpdateListener'; +import type {DeferredPurchasesListener} from './dto/DeferredPurchasesListener'; class QonversionConfig { readonly projectKey: string; readonly launchMode: LaunchMode; readonly environment: Environment; readonly entitlementsCacheLifetime: EntitlementsCacheLifetime; + /** @deprecated Use {@link deferredPurchasesListener} instead. */ readonly entitlementsUpdateListener: EntitlementsUpdateListener | undefined; readonly proxyUrl: string | undefined; readonly kidsMode: boolean; + readonly deferredPurchasesListener: DeferredPurchasesListener | undefined; constructor( projectKey: string, @@ -16,8 +19,9 @@ class QonversionConfig { environment: Environment = Environment.PRODUCTION, entitlementsCacheLifetime: EntitlementsCacheLifetime = EntitlementsCacheLifetime.MONTH, entitlementsUpdateListener: EntitlementsUpdateListener | undefined = undefined, - proxyUrl: string | undefined, - kidsMode: boolean = false + proxyUrl: string | undefined = undefined, + kidsMode: boolean = false, + deferredPurchasesListener: DeferredPurchasesListener | undefined = undefined, ) { this.projectKey = projectKey; this.launchMode = launchMode; @@ -26,6 +30,7 @@ class QonversionConfig { this.entitlementsUpdateListener = entitlementsUpdateListener; this.proxyUrl = proxyUrl; this.kidsMode = kidsMode; + this.deferredPurchasesListener = deferredPurchasesListener; } } diff --git a/src/QonversionConfigBuilder.ts b/src/QonversionConfigBuilder.ts index 03e606f6..fad1f1db 100644 --- a/src/QonversionConfigBuilder.ts +++ b/src/QonversionConfigBuilder.ts @@ -1,5 +1,6 @@ import {EntitlementsCacheLifetime, Environment, LaunchMode} from './dto/enums'; import type {EntitlementsUpdateListener} from './dto/EntitlementsUpdateListener'; +import type {DeferredPurchasesListener} from './dto/DeferredPurchasesListener'; import QonversionConfig from './QonversionConfig'; class QonversionConfigBuilder { @@ -14,6 +15,7 @@ class QonversionConfigBuilder { private environment: Environment = Environment.PRODUCTION; private entitlementsCacheLifetime: EntitlementsCacheLifetime = EntitlementsCacheLifetime.MONTH; private entitlementsUpdateListener: EntitlementsUpdateListener | undefined = undefined; + private deferredPurchasesListener: DeferredPurchasesListener | undefined = undefined; private proxyUrl: string | undefined = undefined; private kidsMode: boolean = false; @@ -51,12 +53,31 @@ class QonversionConfigBuilder { * * @param entitlementsUpdateListener listener to be called when entitlements update. * @return builder instance for chain calls. + * @deprecated Use {@link setDeferredPurchasesListener} instead. */ setEntitlementsUpdateListener(entitlementsUpdateListener: EntitlementsUpdateListener): QonversionConfigBuilder { this.entitlementsUpdateListener = entitlementsUpdateListener; return this; } + /** + * Provide a listener to be notified about deferred purchase completions. + * + * Deferred purchases happen when transactions require additional steps to complete, + * such as SCA (Strong Customer Authentication), Ask to Buy, or other pending transactions. + * This listener will be called when such purchases are finalized. + * + * Make sure you provide this listener for being up-to-date with deferred purchase completions. + * Also, please, consider that this listener should live for the whole lifetime of the application. + * + * @param listener listener to be called when a deferred purchase completes. + * @return builder instance for chain calls. + */ + setDeferredPurchasesListener(listener: DeferredPurchasesListener): QonversionConfigBuilder { + this.deferredPurchasesListener = listener; + return this; + } + /** * Provide a URL to your proxy server which will redirect all the requests from the app * to our API. Please, contact us before using this feature. @@ -94,7 +115,8 @@ class QonversionConfigBuilder { this.entitlementsCacheLifetime, this.entitlementsUpdateListener, this.proxyUrl, - this.kidsMode + this.kidsMode, + this.deferredPurchasesListener, ) } } diff --git a/src/dto/DeferredPurchasesListener.ts b/src/dto/DeferredPurchasesListener.ts new file mode 100644 index 00000000..9bf29af8 --- /dev/null +++ b/src/dto/DeferredPurchasesListener.ts @@ -0,0 +1,11 @@ +import Entitlement from './Entitlement'; + +export interface DeferredPurchasesListener { + + /** + * Called when a deferred purchase completes. + * For example, when pending purchases like SCA, Ask to buy, etc., are approved and finalized. + * @param entitlements the user's entitlements after the deferred purchase completion. + */ + onDeferredPurchaseCompleted(entitlements: Map): void; +} diff --git a/src/dto/EntitlementsUpdateListener.ts b/src/dto/EntitlementsUpdateListener.ts index 48bbef5c..54eab729 100644 --- a/src/dto/EntitlementsUpdateListener.ts +++ b/src/dto/EntitlementsUpdateListener.ts @@ -1,5 +1,8 @@ import Entitlement from './Entitlement'; +/** + * @deprecated Use {@link DeferredPurchasesListener} instead. + */ export interface EntitlementsUpdateListener { /** diff --git a/src/index.ts b/src/index.ts index f54cbff6..d9d512c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { default as QonversionConfig } from './QonversionConfig'; export { default as QonversionConfigBuilder } from './QonversionConfigBuilder'; export type { EntitlementsUpdateListener } from './dto/EntitlementsUpdateListener'; +export type { DeferredPurchasesListener } from './dto/DeferredPurchasesListener'; export * from './dto/enums'; export { default as IntroEligibility } from './dto/IntroEligibility'; export { default as Offering } from './dto/Offering'; diff --git a/src/internal/QonversionInternal.ts b/src/internal/QonversionInternal.ts index b69a12ac..13562c42 100644 --- a/src/internal/QonversionInternal.ts +++ b/src/internal/QonversionInternal.ts @@ -8,6 +8,7 @@ import Product from "../dto/Product"; import PurchaseResult from "../dto/PurchaseResult"; import {isAndroid, isIos} from "./utils"; import type {EntitlementsUpdateListener} from '../dto/EntitlementsUpdateListener'; +import type {DeferredPurchasesListener} from '../dto/DeferredPurchasesListener'; import type {PromoPurchasesListener} from '../dto/PromoPurchasesListener'; import User from '../dto/User'; import PurchaseOptions from '../dto/PurchaseOptions'; @@ -29,7 +30,7 @@ export const sdkSource = "rn"; export default class QonversionInternal implements QonversionApi { - private entitlementsUpdateListener: EntitlementsUpdateListener | null = null; + private deferredPurchasesListener: DeferredPurchasesListener | null = null; private promoPurchasesDelegate: PromoPurchasesListener | null = null; constructor(qonversionConfig: QonversionConfig) { @@ -43,7 +44,9 @@ export default class QonversionInternal implements QonversionApi { qonversionConfig.kidsMode ); - if (qonversionConfig.entitlementsUpdateListener) { + if (qonversionConfig.deferredPurchasesListener) { + this.setDeferredPurchasesListener(qonversionConfig.deferredPurchasesListener); + } else if (qonversionConfig.entitlementsUpdateListener) { this.setEntitlementsUpdateListener(qonversionConfig.entitlementsUpdateListener); } } @@ -382,9 +385,9 @@ export default class QonversionInternal implements QonversionApi { return; } - private entitlementsUpdatedEventHandler = (payload: Object) => { + private deferredPurchaseCompletedEventHandler = (payload: Object) => { const entitlements = Mapper.convertEntitlements(payload as Record); - this.entitlementsUpdateListener?.onEntitlementsUpdated(entitlements); + this.deferredPurchasesListener?.onDeferredPurchaseCompleted(entitlements); } private promoPurchaseReceivedEventHandler = (productId: string) => { @@ -396,12 +399,20 @@ export default class QonversionInternal implements QonversionApi { this.promoPurchasesDelegate?.onPromoPurchaseReceived(productId, promoPurchaseExecutor); } - setEntitlementsUpdateListener(listener: EntitlementsUpdateListener) { - if (this.entitlementsUpdateListener == null) { - RNQonversion.onEntitlementsUpdated(this.entitlementsUpdatedEventHandler); + setDeferredPurchasesListener(listener: DeferredPurchasesListener) { + if (this.deferredPurchasesListener == null) { + RNQonversion.onDeferredPurchaseCompleted(this.deferredPurchaseCompletedEventHandler); } - - this.entitlementsUpdateListener = listener; + + this.deferredPurchasesListener = listener; + } + + setEntitlementsUpdateListener(listener: EntitlementsUpdateListener) { + this.setDeferredPurchasesListener({ + onDeferredPurchaseCompleted: (entitlements) => { + listener.onEntitlementsUpdated(entitlements); + }, + }); } setPromoPurchasesDelegate(delegate: PromoPurchasesListener) { diff --git a/src/internal/__tests__/QonversionInternal.test.ts b/src/internal/__tests__/QonversionInternal.test.ts new file mode 100644 index 00000000..b75a96d2 --- /dev/null +++ b/src/internal/__tests__/QonversionInternal.test.ts @@ -0,0 +1,234 @@ +import type { EntitlementsUpdateListener } from '../../dto/EntitlementsUpdateListener'; +import type { DeferredPurchasesListener } from '../../dto/DeferredPurchasesListener'; +import type { QEntitlement } from '../Mapper'; + +// Capture event handlers registered on the native module mock +const eventHandlers: Record = {}; + +function fireEvent(name: string, payload: unknown) { + const handler = eventHandlers[name]; + if (!handler) { + throw new Error(`No handler registered for event "${name}"`); + } + handler(payload); +} + +jest.mock('../specs/NativeQonversionModule', () => ({ + __esModule: true, + default: { + storeSDKInfo: jest.fn(), + initializeSdk: jest.fn(), + onEntitlementsUpdated: jest.fn((handler: Function) => { + eventHandlers['onEntitlementsUpdated'] = handler; + }), + onDeferredPurchaseCompleted: jest.fn((handler: Function) => { + eventHandlers['onDeferredPurchaseCompleted'] = handler; + }), + onPromoPurchaseReceived: jest.fn(), + }, +})); + +jest.mock('../Mapper', () => ({ + __esModule: true, + default: { + convertEntitlements: jest.fn((payload: Record) => { + const map = new Map(); + for (const [key, value] of Object.entries(payload)) { + map.set(key, value); + } + return map; + }), + }, +})); + +import QonversionInternal from '../QonversionInternal'; +import QonversionConfig from '../../QonversionConfig'; +import { Environment, EntitlementsCacheLifetime, LaunchMode } from '../../dto/enums'; +import RNQonversion from '../specs/NativeQonversionModule'; + +function createConfig() { + return new QonversionConfig( + 'test_key', + LaunchMode.SUBSCRIPTION_MANAGEMENT, + Environment.SANDBOX, + EntitlementsCacheLifetime.MONTH, + undefined, // entitlementsUpdateListener + undefined, // proxyUrl + false, // kidsMode + ); +} + +const sampleEntitlements: Record = { + premium: { id: 'premium', isActive: true } as unknown as QEntitlement, +}; + +describe('QonversionInternal – DeferredPurchasesListener', () => { + beforeEach(() => { + jest.clearAllMocks(); + for (const key of Object.keys(eventHandlers)) { + delete eventHandlers[key]; + } + }); + + it('setDeferredPurchasesListener registers for onDeferredPurchaseCompleted event', () => { + const _instance = new QonversionInternal(createConfig()); + const listener: DeferredPurchasesListener = { + onDeferredPurchaseCompleted: jest.fn(), + }; + + _instance.setDeferredPurchasesListener(listener); + + expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + }); + + it('setDeferredPurchasesListener only subscribes to native event once', () => { + const instance = new QonversionInternal(createConfig()); + const listener1: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + const listener2: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + instance.setDeferredPurchasesListener(listener1); + instance.setDeferredPurchasesListener(listener2); + + expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + }); + + it('calls new listener when onDeferredPurchaseCompleted event fires', () => { + const instance = new QonversionInternal(createConfig()); + const listener: DeferredPurchasesListener = { + onDeferredPurchaseCompleted: jest.fn(), + }; + + instance.setDeferredPurchasesListener(listener); + fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); + + expect(listener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + const entitlementsArg = (listener.onDeferredPurchaseCompleted as jest.Mock).mock.calls[0][0]; + expect(entitlementsArg.get('premium')).toBeDefined(); + }); + + it('replaces previous listener when setting a new one', () => { + const instance = new QonversionInternal(createConfig()); + const listener1: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + const listener2: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + instance.setDeferredPurchasesListener(listener1); + instance.setDeferredPurchasesListener(listener2); + fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); + + expect(listener1.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); + expect(listener2.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + }); +}); + +describe('QonversionInternal – deprecated setEntitlementsUpdateListener', () => { + beforeEach(() => { + jest.clearAllMocks(); + for (const key of Object.keys(eventHandlers)) { + delete eventHandlers[key]; + } + }); + + it('wraps old listener via adapter and subscribes to onDeferredPurchaseCompleted', () => { + const instance = new QonversionInternal(createConfig()); + const oldListener: EntitlementsUpdateListener = { + onEntitlementsUpdated: jest.fn(), + }; + + instance.setEntitlementsUpdateListener(oldListener); + + // Should subscribe to the new event (adapter pattern) + expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + }); + + it('old listener receives events via adapter when onDeferredPurchaseCompleted fires', () => { + const instance = new QonversionInternal(createConfig()); + const oldListener: EntitlementsUpdateListener = { + onEntitlementsUpdated: jest.fn(), + }; + + instance.setEntitlementsUpdateListener(oldListener); + fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); + + expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); + const entitlementsArg = (oldListener.onEntitlementsUpdated as jest.Mock).mock.calls[0][0]; + expect(entitlementsArg.get('premium')).toBeDefined(); + }); + + it('old listener set via config still works', () => { + const oldListener: EntitlementsUpdateListener = { + onEntitlementsUpdated: jest.fn(), + }; + + const config = new QonversionConfig( + 'test_key', + LaunchMode.SUBSCRIPTION_MANAGEMENT, + Environment.SANDBOX, + EntitlementsCacheLifetime.MONTH, + oldListener, + undefined, + false, + ); + + void new QonversionInternal(config); + fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); + + expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); + }); +}); + +describe('QonversionInternal – config with deferredPurchasesListener', () => { + beforeEach(() => { + jest.clearAllMocks(); + for (const key of Object.keys(eventHandlers)) { + delete eventHandlers[key]; + } + }); + + it('uses deferredPurchasesListener from config when provided', () => { + const newListener: DeferredPurchasesListener = { + onDeferredPurchaseCompleted: jest.fn(), + }; + + const config = new QonversionConfig( + 'test_key', + LaunchMode.SUBSCRIPTION_MANAGEMENT, + Environment.SANDBOX, + EntitlementsCacheLifetime.MONTH, + undefined, // old listener + undefined, // proxyUrl + false, // kidsMode + newListener, // deferredPurchasesListener + ); + + void new QonversionInternal(config); + fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); + + expect(newListener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + }); + + it('prefers deferredPurchasesListener over entitlementsUpdateListener in config', () => { + const oldListener: EntitlementsUpdateListener = { + onEntitlementsUpdated: jest.fn(), + }; + const newListener: DeferredPurchasesListener = { + onDeferredPurchaseCompleted: jest.fn(), + }; + + const config = new QonversionConfig( + 'test_key', + LaunchMode.SUBSCRIPTION_MANAGEMENT, + Environment.SANDBOX, + EntitlementsCacheLifetime.MONTH, + oldListener, + undefined, + false, + newListener, + ); + + void new QonversionInternal(config); + fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); + + expect(newListener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(oldListener.onEntitlementsUpdated).not.toHaveBeenCalled(); + }); +}); diff --git a/src/internal/specs/NativeQonversionModule.ts b/src/internal/specs/NativeQonversionModule.ts index f7574040..375d1ae7 100644 --- a/src/internal/specs/NativeQonversionModule.ts +++ b/src/internal/specs/NativeQonversionModule.ts @@ -78,6 +78,7 @@ export interface Spec extends TurboModule { detachUserFromRemoteConfiguration(remoteConfigurationId: string): Promise; readonly onEntitlementsUpdated: EventEmitter; // Record + readonly onDeferredPurchaseCompleted: EventEmitter; // Record readonly onPromoPurchaseReceived: EventEmitter; } From 2a5bf2040dc40d7b7893cb489540021435f468af Mon Sep 17 00:00:00 2001 From: NickSxti Date: Tue, 10 Mar 2026 21:20:24 +0400 Subject: [PATCH 2/4] Fix: separate listener slots instead of adapter pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter pattern had a future-breaking bug: when sandwich adds real deferred purchase filtering, old customers using setEntitlementsUpdateListener would silently lose non-deferred updates because the adapter routed them through onDeferredPurchaseCompleted. Fix: two independent listener slots, each with its own native event. - setEntitlementsUpdateListener → onEntitlementsUpdated (all updates) - setDeferredPurchasesListener → onDeferredPurchaseCompleted (deferred only) Both can coexist. Old code is future-proof when filtering is added. Tests updated: 11 cases (was 9). Co-Authored-By: Claude Opus 4.6 --- src/internal/QonversionInternal.ts | 28 ++++--- .../__tests__/QonversionInternal.test.ts | 77 ++++++++++++++----- 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/src/internal/QonversionInternal.ts b/src/internal/QonversionInternal.ts index 13562c42..5045b7dd 100644 --- a/src/internal/QonversionInternal.ts +++ b/src/internal/QonversionInternal.ts @@ -30,6 +30,7 @@ export const sdkSource = "rn"; export default class QonversionInternal implements QonversionApi { + private entitlementsUpdateListener: EntitlementsUpdateListener | null = null; private deferredPurchasesListener: DeferredPurchasesListener | null = null; private promoPurchasesDelegate: PromoPurchasesListener | null = null; @@ -44,10 +45,12 @@ export default class QonversionInternal implements QonversionApi { qonversionConfig.kidsMode ); + if (qonversionConfig.entitlementsUpdateListener) { + this.setEntitlementsUpdateListener(qonversionConfig.entitlementsUpdateListener); + } + if (qonversionConfig.deferredPurchasesListener) { this.setDeferredPurchasesListener(qonversionConfig.deferredPurchasesListener); - } else if (qonversionConfig.entitlementsUpdateListener) { - this.setEntitlementsUpdateListener(qonversionConfig.entitlementsUpdateListener); } } @@ -385,6 +388,11 @@ export default class QonversionInternal implements QonversionApi { return; } + private entitlementsUpdatedEventHandler = (payload: Object) => { + const entitlements = Mapper.convertEntitlements(payload as Record); + this.entitlementsUpdateListener?.onEntitlementsUpdated(entitlements); + } + private deferredPurchaseCompletedEventHandler = (payload: Object) => { const entitlements = Mapper.convertEntitlements(payload as Record); this.deferredPurchasesListener?.onDeferredPurchaseCompleted(entitlements); @@ -399,6 +407,14 @@ export default class QonversionInternal implements QonversionApi { this.promoPurchasesDelegate?.onPromoPurchaseReceived(productId, promoPurchaseExecutor); } + setEntitlementsUpdateListener(listener: EntitlementsUpdateListener) { + if (this.entitlementsUpdateListener == null) { + RNQonversion.onEntitlementsUpdated(this.entitlementsUpdatedEventHandler); + } + + this.entitlementsUpdateListener = listener; + } + setDeferredPurchasesListener(listener: DeferredPurchasesListener) { if (this.deferredPurchasesListener == null) { RNQonversion.onDeferredPurchaseCompleted(this.deferredPurchaseCompletedEventHandler); @@ -407,14 +423,6 @@ export default class QonversionInternal implements QonversionApi { this.deferredPurchasesListener = listener; } - setEntitlementsUpdateListener(listener: EntitlementsUpdateListener) { - this.setDeferredPurchasesListener({ - onDeferredPurchaseCompleted: (entitlements) => { - listener.onEntitlementsUpdated(entitlements); - }, - }); - } - setPromoPurchasesDelegate(delegate: PromoPurchasesListener) { if (!isIos()) { return; diff --git a/src/internal/__tests__/QonversionInternal.test.ts b/src/internal/__tests__/QonversionInternal.test.ts index b75a96d2..930b02df 100644 --- a/src/internal/__tests__/QonversionInternal.test.ts +++ b/src/internal/__tests__/QonversionInternal.test.ts @@ -71,12 +71,12 @@ describe('QonversionInternal – DeferredPurchasesListener', () => { }); it('setDeferredPurchasesListener registers for onDeferredPurchaseCompleted event', () => { - const _instance = new QonversionInternal(createConfig()); + const instance = new QonversionInternal(createConfig()); const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn(), }; - _instance.setDeferredPurchasesListener(listener); + instance.setDeferredPurchasesListener(listener); expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); }); @@ -118,6 +118,15 @@ describe('QonversionInternal – DeferredPurchasesListener', () => { expect(listener1.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); expect(listener2.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); }); + + it('does not fire old listener when only new listener is set', () => { + const instance = new QonversionInternal(createConfig()); + const newListener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + instance.setDeferredPurchasesListener(newListener); + + expect(RNQonversion.onEntitlementsUpdated).not.toHaveBeenCalled(); + }); }); describe('QonversionInternal – deprecated setEntitlementsUpdateListener', () => { @@ -128,7 +137,7 @@ describe('QonversionInternal – deprecated setEntitlementsUpdateListener', () = } }); - it('wraps old listener via adapter and subscribes to onDeferredPurchaseCompleted', () => { + it('subscribes to onEntitlementsUpdated (not onDeferredPurchaseCompleted)', () => { const instance = new QonversionInternal(createConfig()); const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn(), @@ -136,18 +145,18 @@ describe('QonversionInternal – deprecated setEntitlementsUpdateListener', () = instance.setEntitlementsUpdateListener(oldListener); - // Should subscribe to the new event (adapter pattern) - expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalledTimes(1); + expect(RNQonversion.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); }); - it('old listener receives events via adapter when onDeferredPurchaseCompleted fires', () => { + it('old listener receives events when onEntitlementsUpdated fires', () => { const instance = new QonversionInternal(createConfig()); const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn(), }; instance.setEntitlementsUpdateListener(oldListener); - fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); + fireEvent('onEntitlementsUpdated', sampleEntitlements); expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); const entitlementsArg = (oldListener.onEntitlementsUpdated as jest.Mock).mock.calls[0][0]; @@ -170,13 +179,13 @@ describe('QonversionInternal – deprecated setEntitlementsUpdateListener', () = ); void new QonversionInternal(config); - fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); + fireEvent('onEntitlementsUpdated', sampleEntitlements); expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); }); }); -describe('QonversionInternal – config with deferredPurchasesListener', () => { +describe('QonversionInternal – both listeners coexist', () => { beforeEach(() => { jest.clearAllMocks(); for (const key of Object.keys(eventHandlers)) { @@ -184,7 +193,10 @@ describe('QonversionInternal – config with deferredPurchasesListener', () => { } }); - it('uses deferredPurchasesListener from config when provided', () => { + it('both listeners can be set independently via config', () => { + const oldListener: EntitlementsUpdateListener = { + onEntitlementsUpdated: jest.fn(), + }; const newListener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn(), }; @@ -194,19 +206,19 @@ describe('QonversionInternal – config with deferredPurchasesListener', () => { LaunchMode.SUBSCRIPTION_MANAGEMENT, Environment.SANDBOX, EntitlementsCacheLifetime.MONTH, - undefined, // old listener - undefined, // proxyUrl - false, // kidsMode - newListener, // deferredPurchasesListener + oldListener, + undefined, + false, + newListener, ); void new QonversionInternal(config); - fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); - expect(newListener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalledTimes(1); + expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); }); - it('prefers deferredPurchasesListener over entitlementsUpdateListener in config', () => { + it('each listener fires from its own native event', () => { const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn(), }; @@ -225,10 +237,39 @@ describe('QonversionInternal – config with deferredPurchasesListener', () => { newListener, ); + void new QonversionInternal(config); + + // Fire old event — only old listener + fireEvent('onEntitlementsUpdated', sampleEntitlements); + expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); + expect(newListener.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); + + // Fire new event — only new listener + fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); + expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); // still 1 + expect(newListener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + }); + + it('config with only new listener uses deferredPurchasesListener', () => { + const newListener: DeferredPurchasesListener = { + onDeferredPurchaseCompleted: jest.fn(), + }; + + const config = new QonversionConfig( + 'test_key', + LaunchMode.SUBSCRIPTION_MANAGEMENT, + Environment.SANDBOX, + EntitlementsCacheLifetime.MONTH, + undefined, + undefined, + false, + newListener, + ); + void new QonversionInternal(config); fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); expect(newListener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); - expect(oldListener.onEntitlementsUpdated).not.toHaveBeenCalled(); + expect(RNQonversion.onEntitlementsUpdated).not.toHaveBeenCalled(); }); }); From dda31efd78dbaa043c145b47526c481e561079ba Mon Sep 17 00:00:00 2001 From: NickSxti Date: Tue, 10 Mar 2026 21:30:23 +0400 Subject: [PATCH 3/4] Add JS-layer deferred purchase filtering with TDD tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track pending purchase product IDs from purchaseWithResult, correlate against entitlement updates to fire deferredPurchasesListener only for actual deferred purchase completions. Revert native-side dual emission back to single onEntitlementsUpdated — filtering is handled in JS. Co-Authored-By: Claude Opus 4.6 --- .../reactnativesdk/QonversionModule.kt | 1 - ios/RNQonversion.mm | 1 - src/internal/QonversionInternal.ts | 39 ++- .../__tests__/QonversionInternal.test.ts | 223 ++++++++++-------- 4 files changed, 156 insertions(+), 108 deletions(-) diff --git a/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt b/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt index 0455b002..0565d170 100644 --- a/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt +++ b/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt @@ -297,7 +297,6 @@ class QonversionModule(reactContext: ReactApplicationContext) : NativeQonversion override fun onEntitlementsUpdated(entitlements: BridgeData) { val mappedEntitlements = EntitiesConverter.convertMapToWritableMap(entitlements) emitOnEntitlementsUpdated(mappedEntitlements) - emitOnDeferredPurchaseCompleted(mappedEntitlements) } companion object { diff --git a/ios/RNQonversion.mm b/ios/RNQonversion.mm index c2634417..2088da9d 100644 --- a/ios/RNQonversion.mm +++ b/ios/RNQonversion.mm @@ -312,7 +312,6 @@ - (void)updatePurchase:(nonnull NSString *)productId offerId:(NSString * _Nullab - (void)qonversionDidReceiveUpdatedEntitlements:(NSDictionary * _Nonnull)entitlements { @try { [self emitOnEntitlementsUpdated:entitlements]; - [self emitOnDeferredPurchaseCompleted:entitlements]; } @catch (NSException *exception) { QNR_LOG_EXCEPTION("qonversionDidReceiveUpdatedEntitlements", exception); } diff --git a/src/internal/QonversionInternal.ts b/src/internal/QonversionInternal.ts index 5045b7dd..a20a4324 100644 --- a/src/internal/QonversionInternal.ts +++ b/src/internal/QonversionInternal.ts @@ -33,6 +33,8 @@ export default class QonversionInternal implements QonversionApi { private entitlementsUpdateListener: EntitlementsUpdateListener | null = null; private deferredPurchasesListener: DeferredPurchasesListener | null = null; private promoPurchasesDelegate: PromoPurchasesListener | null = null; + private pendingPurchaseProductIds: Set = new Set(); + private entitlementsEventSubscribed = false; constructor(qonversionConfig: QonversionConfig) { RNQonversion.storeSDKInfo(sdkSource, sdkVersion); @@ -124,6 +126,10 @@ export default class QonversionInternal implements QonversionApi { throw new Error("Failed to parse PurchaseResult"); } + if (mappedResult.isPending) { + this.pendingPurchaseProductIds.add(product.qonversionId); + } + return mappedResult; } @@ -388,14 +394,31 @@ export default class QonversionInternal implements QonversionApi { return; } + private subscribeToEntitlementsEvent() { + if (!this.entitlementsEventSubscribed) { + RNQonversion.onEntitlementsUpdated(this.entitlementsUpdatedEventHandler); + this.entitlementsEventSubscribed = true; + } + } + private entitlementsUpdatedEventHandler = (payload: Object) => { const entitlements = Mapper.convertEntitlements(payload as Record); + this.entitlementsUpdateListener?.onEntitlementsUpdated(entitlements); + + if (this.deferredPurchasesListener && this.hasPendingPurchaseCompleted(entitlements)) { + this.deferredPurchasesListener.onDeferredPurchaseCompleted(entitlements); + } } - private deferredPurchaseCompletedEventHandler = (payload: Object) => { - const entitlements = Mapper.convertEntitlements(payload as Record); - this.deferredPurchasesListener?.onDeferredPurchaseCompleted(entitlements); + private hasPendingPurchaseCompleted(entitlements: Map): boolean { + for (const [, entitlement] of entitlements) { + if (entitlement.isActive && this.pendingPurchaseProductIds.has(entitlement.productId)) { + this.pendingPurchaseProductIds.delete(entitlement.productId); + return true; + } + } + return false; } private promoPurchaseReceivedEventHandler = (productId: string) => { @@ -408,18 +431,12 @@ export default class QonversionInternal implements QonversionApi { } setEntitlementsUpdateListener(listener: EntitlementsUpdateListener) { - if (this.entitlementsUpdateListener == null) { - RNQonversion.onEntitlementsUpdated(this.entitlementsUpdatedEventHandler); - } - + this.subscribeToEntitlementsEvent(); this.entitlementsUpdateListener = listener; } setDeferredPurchasesListener(listener: DeferredPurchasesListener) { - if (this.deferredPurchasesListener == null) { - RNQonversion.onDeferredPurchaseCompleted(this.deferredPurchaseCompletedEventHandler); - } - + this.subscribeToEntitlementsEvent(); this.deferredPurchasesListener = listener; } diff --git a/src/internal/__tests__/QonversionInternal.test.ts b/src/internal/__tests__/QonversionInternal.test.ts index 930b02df..660ff470 100644 --- a/src/internal/__tests__/QonversionInternal.test.ts +++ b/src/internal/__tests__/QonversionInternal.test.ts @@ -1,6 +1,8 @@ import type { EntitlementsUpdateListener } from '../../dto/EntitlementsUpdateListener'; import type { DeferredPurchasesListener } from '../../dto/DeferredPurchasesListener'; import type { QEntitlement } from '../Mapper'; +import { PurchaseResultStatus, PurchaseResultSource } from '../../dto/enums'; +import PurchaseResult from '../../dto/PurchaseResult'; // Capture event handlers registered on the native module mock const eventHandlers: Record = {}; @@ -25,6 +27,7 @@ jest.mock('../specs/NativeQonversionModule', () => ({ eventHandlers['onDeferredPurchaseCompleted'] = handler; }), onPromoPurchaseReceived: jest.fn(), + purchaseWithResult: jest.fn(), }, })); @@ -38,6 +41,7 @@ jest.mock('../Mapper', () => ({ } return map; }), + convertPurchaseResult: jest.fn(), }, })); @@ -45,6 +49,7 @@ import QonversionInternal from '../QonversionInternal'; import QonversionConfig from '../../QonversionConfig'; import { Environment, EntitlementsCacheLifetime, LaunchMode } from '../../dto/enums'; import RNQonversion from '../specs/NativeQonversionModule'; +import Mapper from '../Mapper'; function createConfig() { return new QonversionConfig( @@ -52,16 +57,39 @@ function createConfig() { LaunchMode.SUBSCRIPTION_MANAGEMENT, Environment.SANDBOX, EntitlementsCacheLifetime.MONTH, - undefined, // entitlementsUpdateListener - undefined, // proxyUrl - false, // kidsMode + undefined, + undefined, + false, ); } -const sampleEntitlements: Record = { - premium: { id: 'premium', isActive: true } as unknown as QEntitlement, +// Entitlements payload where product "premium_product" is active +const entitlementsWithActiveProduct: Record = { + premium: { id: 'premium', productId: 'premium_product', isActive: true } as unknown as QEntitlement, }; +// Entitlements payload with no matching pending product +const entitlementsUnrelated: Record = { + basic: { id: 'basic', productId: 'basic_product', isActive: true } as unknown as QEntitlement, +}; + +// Minimal Product-like object for purchaseWithResult +const mockProduct = { qonversionId: 'premium_product' } as any; + +function mockPendingPurchaseResult() { + (RNQonversion.purchaseWithResult as jest.Mock).mockResolvedValue({}); + (Mapper.convertPurchaseResult as jest.Mock).mockReturnValue( + new PurchaseResult(PurchaseResultStatus.PENDING, null, null, false, PurchaseResultSource.API, null), + ); +} + +function mockSuccessPurchaseResult() { + (RNQonversion.purchaseWithResult as jest.Mock).mockResolvedValue({}); + (Mapper.convertPurchaseResult as jest.Mock).mockReturnValue( + new PurchaseResult(PurchaseResultStatus.SUCCESS, new Map(), null, false, PurchaseResultSource.API, null), + ); +} + describe('QonversionInternal – DeferredPurchasesListener', () => { beforeEach(() => { jest.clearAllMocks(); @@ -70,7 +98,7 @@ describe('QonversionInternal – DeferredPurchasesListener', () => { } }); - it('setDeferredPurchasesListener registers for onDeferredPurchaseCompleted event', () => { + it('setDeferredPurchasesListener subscribes to onEntitlementsUpdated', () => { const instance = new QonversionInternal(createConfig()); const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn(), @@ -78,55 +106,98 @@ describe('QonversionInternal – DeferredPurchasesListener', () => { instance.setDeferredPurchasesListener(listener); - expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalled(); }); - it('setDeferredPurchasesListener only subscribes to native event once', () => { + it('does NOT fire new listener when no pending purchases tracked', () => { const instance = new QonversionInternal(createConfig()); - const listener1: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; - const listener2: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; - instance.setDeferredPurchasesListener(listener1); - instance.setDeferredPurchasesListener(listener2); + instance.setDeferredPurchasesListener(listener); + fireEvent('onEntitlementsUpdated', entitlementsUnrelated); - expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(listener.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); }); - it('calls new listener when onDeferredPurchaseCompleted event fires', () => { + it('fires new listener when pending purchase product is active in entitlements update', async () => { const instance = new QonversionInternal(createConfig()); - const listener: DeferredPurchasesListener = { - onDeferredPurchaseCompleted: jest.fn(), - }; + const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + instance.setDeferredPurchasesListener(listener); + + // Simulate a pending purchase + mockPendingPurchaseResult(); + await instance.purchaseWithResult(mockProduct, undefined); + + // Fire entitlements update with the pending product now active + fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); + + expect(listener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + }); + + it('does NOT fire new listener for unrelated entitlement update after pending purchase', async () => { + const instance = new QonversionInternal(createConfig()); + const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + instance.setDeferredPurchasesListener(listener); + + // Simulate a pending purchase for "premium_product" + mockPendingPurchaseResult(); + await instance.purchaseWithResult(mockProduct, undefined); + + // Fire entitlements update with a DIFFERENT product (not the pending one) + fireEvent('onEntitlementsUpdated', entitlementsUnrelated); + + expect(listener.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); + }); + + it('removes product from tracking after deferred purchase completes (no double-fire)', async () => { + const instance = new QonversionInternal(createConfig()); + const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; instance.setDeferredPurchasesListener(listener); - fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); + mockPendingPurchaseResult(); + await instance.purchaseWithResult(mockProduct, undefined); + + // First update: deferred purchase completes + fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); expect(listener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); - const entitlementsArg = (listener.onDeferredPurchaseCompleted as jest.Mock).mock.calls[0][0]; - expect(entitlementsArg.get('premium')).toBeDefined(); + + // Second update: same entitlements, but product already cleared from tracking + fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); + expect(listener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); // still 1 + }); + + it('does NOT track product when purchaseWithResult returns success', async () => { + const instance = new QonversionInternal(createConfig()); + const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + + instance.setDeferredPurchasesListener(listener); + + mockSuccessPurchaseResult(); + await instance.purchaseWithResult(mockProduct, undefined); + + fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); + + expect(listener.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); }); - it('replaces previous listener when setting a new one', () => { + it('replaces previous listener when setting a new one', async () => { const instance = new QonversionInternal(createConfig()); const listener1: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; const listener2: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; instance.setDeferredPurchasesListener(listener1); instance.setDeferredPurchasesListener(listener2); - fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); + + mockPendingPurchaseResult(); + await instance.purchaseWithResult(mockProduct, undefined); + fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); expect(listener1.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); expect(listener2.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); }); - - it('does not fire old listener when only new listener is set', () => { - const instance = new QonversionInternal(createConfig()); - const newListener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; - - instance.setDeferredPurchasesListener(newListener); - - expect(RNQonversion.onEntitlementsUpdated).not.toHaveBeenCalled(); - }); }); describe('QonversionInternal – deprecated setEntitlementsUpdateListener', () => { @@ -137,36 +208,27 @@ describe('QonversionInternal – deprecated setEntitlementsUpdateListener', () = } }); - it('subscribes to onEntitlementsUpdated (not onDeferredPurchaseCompleted)', () => { + it('subscribes to onEntitlementsUpdated', () => { const instance = new QonversionInternal(createConfig()); - const oldListener: EntitlementsUpdateListener = { - onEntitlementsUpdated: jest.fn(), - }; + const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; instance.setEntitlementsUpdateListener(oldListener); - expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalledTimes(1); - expect(RNQonversion.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); + expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalled(); }); - it('old listener receives events when onEntitlementsUpdated fires', () => { + it('always fires for ALL entitlement updates (no filtering)', () => { const instance = new QonversionInternal(createConfig()); - const oldListener: EntitlementsUpdateListener = { - onEntitlementsUpdated: jest.fn(), - }; + const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; instance.setEntitlementsUpdateListener(oldListener); - fireEvent('onEntitlementsUpdated', sampleEntitlements); + fireEvent('onEntitlementsUpdated', entitlementsUnrelated); expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); - const entitlementsArg = (oldListener.onEntitlementsUpdated as jest.Mock).mock.calls[0][0]; - expect(entitlementsArg.get('premium')).toBeDefined(); }); it('old listener set via config still works', () => { - const oldListener: EntitlementsUpdateListener = { - onEntitlementsUpdated: jest.fn(), - }; + const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; const config = new QonversionConfig( 'test_key', @@ -179,7 +241,7 @@ describe('QonversionInternal – deprecated setEntitlementsUpdateListener', () = ); void new QonversionInternal(config); - fireEvent('onEntitlementsUpdated', sampleEntitlements); + fireEvent('onEntitlementsUpdated', entitlementsUnrelated); expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); }); @@ -193,38 +255,9 @@ describe('QonversionInternal – both listeners coexist', () => { } }); - it('both listeners can be set independently via config', () => { - const oldListener: EntitlementsUpdateListener = { - onEntitlementsUpdated: jest.fn(), - }; - const newListener: DeferredPurchasesListener = { - onDeferredPurchaseCompleted: jest.fn(), - }; - - const config = new QonversionConfig( - 'test_key', - LaunchMode.SUBSCRIPTION_MANAGEMENT, - Environment.SANDBOX, - EntitlementsCacheLifetime.MONTH, - oldListener, - undefined, - false, - newListener, - ); - - void new QonversionInternal(config); - - expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalledTimes(1); - expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); - }); - - it('each listener fires from its own native event', () => { - const oldListener: EntitlementsUpdateListener = { - onEntitlementsUpdated: jest.fn(), - }; - const newListener: DeferredPurchasesListener = { - onDeferredPurchaseCompleted: jest.fn(), - }; + it('old listener fires for all updates, new listener only for deferred', async () => { + const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; + const newListener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; const config = new QonversionConfig( 'test_key', @@ -237,39 +270,39 @@ describe('QonversionInternal – both listeners coexist', () => { newListener, ); - void new QonversionInternal(config); + const instance = new QonversionInternal(config); - // Fire old event — only old listener - fireEvent('onEntitlementsUpdated', sampleEntitlements); + // Unrelated update — old fires, new does NOT + fireEvent('onEntitlementsUpdated', entitlementsUnrelated); expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); expect(newListener.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); - // Fire new event — only new listener - fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); - expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); // still 1 - expect(newListener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + // Now make a pending purchase and fire matching update + mockPendingPurchaseResult(); + await instance.purchaseWithResult(mockProduct, undefined); + fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); + + expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(2); // fires for all + expect(newListener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); // only deferred }); - it('config with only new listener uses deferredPurchasesListener', () => { - const newListener: DeferredPurchasesListener = { - onDeferredPurchaseCompleted: jest.fn(), - }; + it('subscribes to onEntitlementsUpdated only once when both listeners set', () => { + const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; + const newListener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; const config = new QonversionConfig( 'test_key', LaunchMode.SUBSCRIPTION_MANAGEMENT, Environment.SANDBOX, EntitlementsCacheLifetime.MONTH, - undefined, + oldListener, undefined, false, newListener, ); void new QonversionInternal(config); - fireEvent('onDeferredPurchaseCompleted', sampleEntitlements); - expect(newListener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); - expect(RNQonversion.onEntitlementsUpdated).not.toHaveBeenCalled(); + expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalledTimes(1); }); }); From 9a5f1cb972cbd825e037ff46e4be8cd6ca19a485 Mon Sep 17 00:00:00 2001 From: NickSxti Date: Wed, 18 Mar 2026 15:57:45 +0400 Subject: [PATCH 4/4] Rework DeferredPurchasesListener to use native events Replace JS-layer filtering with native DeferredPurchasesListener from Sandwich SDK. The listener now receives a DeferredTransaction object with full transaction details instead of entitlements. - Add DeferredTransaction model - Wire native onDeferredPurchaseCompleted event (iOS + Android) - Remove JS-side pendingPurchaseProductIds tracking - Update DeferredPurchasesListener to use DeferredTransaction - 10 tests passing Co-Authored-By: Claude Opus 4.6 (1M context) --- .../reactnativesdk/QonversionModule.kt | 5 + ios/RNQonversion.mm | 8 + ios/RNQonversionImpl.swift | 5 + src/dto/DeferredPurchasesListener.ts | 7 +- src/dto/DeferredTransaction.ts | 51 ++++ src/index.ts | 1 + src/internal/Mapper.ts | 22 ++ src/internal/QonversionInternal.ts | 31 ++- .../__tests__/QonversionInternal.test.ts | 223 +++++++----------- 9 files changed, 194 insertions(+), 159 deletions(-) create mode 100644 src/dto/DeferredTransaction.ts diff --git a/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt b/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt index 0565d170..2944c743 100644 --- a/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt +++ b/android/src/main/java/com/qonversion/reactnativesdk/QonversionModule.kt @@ -299,6 +299,11 @@ class QonversionModule(reactContext: ReactApplicationContext) : NativeQonversion emitOnEntitlementsUpdated(mappedEntitlements) } + override fun onDeferredPurchaseCompleted(transaction: BridgeData) { + val mappedTransaction = EntitiesConverter.convertMapToWritableMap(transaction) + emitOnDeferredPurchaseCompleted(mappedTransaction) + } + companion object { const val NAME = "RNQonversion" } diff --git a/ios/RNQonversion.mm b/ios/RNQonversion.mm index 2088da9d..77b4277c 100644 --- a/ios/RNQonversion.mm +++ b/ios/RNQonversion.mm @@ -317,6 +317,14 @@ - (void)qonversionDidReceiveUpdatedEntitlements:(NSDictionary * _ } } +- (void)qonversionDidCompleteDeferredPurchase:(NSDictionary * _Nonnull)transaction { + @try { + [self emitOnDeferredPurchaseCompleted:transaction]; + } @catch (NSException *exception) { + QNR_LOG_EXCEPTION("qonversionDidCompleteDeferredPurchase", exception); + } +} + - (void)shouldPurchasePromoProductWith:(NSString * _Nonnull)productId { @try { [self emitOnPromoPurchaseReceived:productId]; diff --git a/ios/RNQonversionImpl.swift b/ios/RNQonversionImpl.swift index 917d3736..5d05de79 100644 --- a/ios/RNQonversionImpl.swift +++ b/ios/RNQonversionImpl.swift @@ -13,6 +13,7 @@ import React public protocol QonversionEventDelegate { func shouldPurchasePromoProduct(with productId: String) func qonversionDidReceiveUpdatedEntitlements(_ entitlements: [String: Any]) + func qonversionDidCompleteDeferredPurchase(_ transaction: [String: Any]) } class QonversionEventHandler: QonversionEventListener { @@ -25,6 +26,10 @@ class QonversionEventHandler: QonversionEventListener { func qonversionDidReceiveUpdatedEntitlements(_ entitlements: [String: Any]) { delegate?.qonversionDidReceiveUpdatedEntitlements(entitlements) } + + func qonversionDidCompleteDeferredPurchase(_ transaction: [String: Any]) { + delegate?.qonversionDidCompleteDeferredPurchase(transaction) + } } @objc diff --git a/src/dto/DeferredPurchasesListener.ts b/src/dto/DeferredPurchasesListener.ts index 9bf29af8..aabc01dd 100644 --- a/src/dto/DeferredPurchasesListener.ts +++ b/src/dto/DeferredPurchasesListener.ts @@ -1,11 +1,12 @@ -import Entitlement from './Entitlement'; +import DeferredTransaction from './DeferredTransaction'; export interface DeferredPurchasesListener { /** * Called when a deferred purchase completes. * For example, when pending purchases like SCA, Ask to buy, etc., are approved and finalized. - * @param entitlements the user's entitlements after the deferred purchase completion. + * Provides full transaction details, including for consumable products without entitlements. + * @param transaction the completed deferred transaction with full details. */ - onDeferredPurchaseCompleted(entitlements: Map): void; + onDeferredPurchaseCompleted(transaction: DeferredTransaction): void; } diff --git a/src/dto/DeferredTransaction.ts b/src/dto/DeferredTransaction.ts new file mode 100644 index 00000000..224b7059 --- /dev/null +++ b/src/dto/DeferredTransaction.ts @@ -0,0 +1,51 @@ +/** + * Represents a completed deferred purchase transaction with full details. + */ +export default class DeferredTransaction { + /** + * Store product identifier. + */ + productId: string; + + /** + * Store transaction identifier. + */ + transactionId: string | null; + + /** + * Original store transaction identifier. + * For subscriptions, this is the ID of the first transaction. + */ + originalTransactionId: string | null; + + /** + * Type of the transaction: Subscription, Consumable, NonConsumable, or Unknown. + */ + type: string; + + /** + * Transaction value. May be 0 if unavailable. + */ + value: number; + + /** + * Currency code (e.g. "USD"). May be null if unavailable. + */ + currency: string | null; + + constructor( + productId: string, + transactionId: string | null, + originalTransactionId: string | null, + type: string, + value: number, + currency: string | null + ) { + this.productId = productId; + this.transactionId = transactionId; + this.originalTransactionId = originalTransactionId; + this.type = type; + this.value = value; + this.currency = currency; + } +} diff --git a/src/index.ts b/src/index.ts index d9d512c5..44f2dbe2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export { default as QonversionConfigBuilder } from './QonversionConfigBuilder'; export type { EntitlementsUpdateListener } from './dto/EntitlementsUpdateListener'; export type { DeferredPurchasesListener } from './dto/DeferredPurchasesListener'; +export { default as DeferredTransaction } from './dto/DeferredTransaction'; export * from './dto/enums'; export { default as IntroEligibility } from './dto/IntroEligibility'; export { default as Offering } from './dto/Offering'; diff --git a/src/internal/Mapper.ts b/src/internal/Mapper.ts index a2671e1f..13be1215 100644 --- a/src/internal/Mapper.ts +++ b/src/internal/Mapper.ts @@ -56,6 +56,7 @@ import QonversionError from '../dto/QonversionError'; import NoCodesError from '../dto/NoCodesError'; import PurchaseResult from '../dto/PurchaseResult'; import StoreTransaction from '../dto/StoreTransaction'; +import DeferredTransaction from '../dto/DeferredTransaction'; export type QProduct = { id: string; @@ -1250,6 +1251,27 @@ class Mapper { } // endregion + + // region DeferredTransaction + + static convertDeferredTransaction( + transaction: Record | null | undefined + ): DeferredTransaction | null { + if (!transaction) { + return null; + } + + return new DeferredTransaction( + transaction.productId ?? '', + transaction.transactionId ?? null, + transaction.originalTransactionId ?? null, + transaction.type ?? 'Unknown', + transaction.value ?? 0, + transaction.currency ?? null + ); + } + + // endregion } export default Mapper; diff --git a/src/internal/QonversionInternal.ts b/src/internal/QonversionInternal.ts index a20a4324..979f3af6 100644 --- a/src/internal/QonversionInternal.ts +++ b/src/internal/QonversionInternal.ts @@ -33,8 +33,8 @@ export default class QonversionInternal implements QonversionApi { private entitlementsUpdateListener: EntitlementsUpdateListener | null = null; private deferredPurchasesListener: DeferredPurchasesListener | null = null; private promoPurchasesDelegate: PromoPurchasesListener | null = null; - private pendingPurchaseProductIds: Set = new Set(); private entitlementsEventSubscribed = false; + private deferredPurchaseEventSubscribed = false; constructor(qonversionConfig: QonversionConfig) { RNQonversion.storeSDKInfo(sdkSource, sdkVersion); @@ -126,10 +126,6 @@ export default class QonversionInternal implements QonversionApi { throw new Error("Failed to parse PurchaseResult"); } - if (mappedResult.isPending) { - this.pendingPurchaseProductIds.add(product.qonversionId); - } - return mappedResult; } @@ -401,24 +397,25 @@ export default class QonversionInternal implements QonversionApi { } } + private subscribeToDeferredPurchaseEvent() { + if (!this.deferredPurchaseEventSubscribed) { + RNQonversion.onDeferredPurchaseCompleted(this.deferredPurchaseCompletedEventHandler); + this.deferredPurchaseEventSubscribed = true; + } + } + private entitlementsUpdatedEventHandler = (payload: Object) => { const entitlements = Mapper.convertEntitlements(payload as Record); this.entitlementsUpdateListener?.onEntitlementsUpdated(entitlements); - - if (this.deferredPurchasesListener && this.hasPendingPurchaseCompleted(entitlements)) { - this.deferredPurchasesListener.onDeferredPurchaseCompleted(entitlements); - } } - private hasPendingPurchaseCompleted(entitlements: Map): boolean { - for (const [, entitlement] of entitlements) { - if (entitlement.isActive && this.pendingPurchaseProductIds.has(entitlement.productId)) { - this.pendingPurchaseProductIds.delete(entitlement.productId); - return true; - } + private deferredPurchaseCompletedEventHandler = (payload: Object) => { + const transaction = Mapper.convertDeferredTransaction(payload as Record); + + if (transaction) { + this.deferredPurchasesListener?.onDeferredPurchaseCompleted(transaction); } - return false; } private promoPurchaseReceivedEventHandler = (productId: string) => { @@ -436,7 +433,7 @@ export default class QonversionInternal implements QonversionApi { } setDeferredPurchasesListener(listener: DeferredPurchasesListener) { - this.subscribeToEntitlementsEvent(); + this.subscribeToDeferredPurchaseEvent(); this.deferredPurchasesListener = listener; } diff --git a/src/internal/__tests__/QonversionInternal.test.ts b/src/internal/__tests__/QonversionInternal.test.ts index 660ff470..e2be1284 100644 --- a/src/internal/__tests__/QonversionInternal.test.ts +++ b/src/internal/__tests__/QonversionInternal.test.ts @@ -1,8 +1,7 @@ import type { EntitlementsUpdateListener } from '../../dto/EntitlementsUpdateListener'; import type { DeferredPurchasesListener } from '../../dto/DeferredPurchasesListener'; import type { QEntitlement } from '../Mapper'; -import { PurchaseResultStatus, PurchaseResultSource } from '../../dto/enums'; -import PurchaseResult from '../../dto/PurchaseResult'; +import DeferredTransaction from '../../dto/DeferredTransaction'; // Capture event handlers registered on the native module mock const eventHandlers: Record = {}; @@ -31,25 +30,38 @@ jest.mock('../specs/NativeQonversionModule', () => ({ }, })); -jest.mock('../Mapper', () => ({ - __esModule: true, - default: { - convertEntitlements: jest.fn((payload: Record) => { - const map = new Map(); - for (const [key, value] of Object.entries(payload)) { - map.set(key, value); - } - return map; - }), - convertPurchaseResult: jest.fn(), - }, -})); +jest.mock('../Mapper', () => { + const MockDeferredTransaction = require('../../dto/DeferredTransaction').default; + return { + __esModule: true, + default: { + convertEntitlements: jest.fn((payload: Record) => { + const map = new Map(); + for (const [key, value] of Object.entries(payload)) { + map.set(key, value); + } + return map; + }), + convertDeferredTransaction: jest.fn((payload: Record) => { + if (!payload) return null; + return new MockDeferredTransaction( + payload.productId ?? '', + payload.transactionId ?? null, + payload.originalTransactionId ?? null, + payload.type ?? 'Unknown', + payload.value ?? 0, + payload.currency ?? null + ); + }), + convertPurchaseResult: jest.fn(), + }, + }; +}); import QonversionInternal from '../QonversionInternal'; import QonversionConfig from '../../QonversionConfig'; import { Environment, EntitlementsCacheLifetime, LaunchMode } from '../../dto/enums'; import RNQonversion from '../specs/NativeQonversionModule'; -import Mapper from '../Mapper'; function createConfig() { return new QonversionConfig( @@ -63,34 +75,20 @@ function createConfig() { ); } -// Entitlements payload where product "premium_product" is active -const entitlementsWithActiveProduct: Record = { - premium: { id: 'premium', productId: 'premium_product', isActive: true } as unknown as QEntitlement, +const sampleTransaction = { + productId: 'premium_product', + transactionId: 'txn_123', + originalTransactionId: 'txn_001', + type: 'Subscription', + value: 9.99, + currency: 'USD', }; -// Entitlements payload with no matching pending product -const entitlementsUnrelated: Record = { - basic: { id: 'basic', productId: 'basic_product', isActive: true } as unknown as QEntitlement, +const entitlementsPayload: Record = { + premium: { id: 'premium', productId: 'premium_product', isActive: true } as unknown as QEntitlement, }; -// Minimal Product-like object for purchaseWithResult -const mockProduct = { qonversionId: 'premium_product' } as any; - -function mockPendingPurchaseResult() { - (RNQonversion.purchaseWithResult as jest.Mock).mockResolvedValue({}); - (Mapper.convertPurchaseResult as jest.Mock).mockReturnValue( - new PurchaseResult(PurchaseResultStatus.PENDING, null, null, false, PurchaseResultSource.API, null), - ); -} - -function mockSuccessPurchaseResult() { - (RNQonversion.purchaseWithResult as jest.Mock).mockResolvedValue({}); - (Mapper.convertPurchaseResult as jest.Mock).mockReturnValue( - new PurchaseResult(PurchaseResultStatus.SUCCESS, new Map(), null, false, PurchaseResultSource.API, null), - ); -} - -describe('QonversionInternal – DeferredPurchasesListener', () => { +describe('QonversionInternal - DeferredPurchasesListener (native event)', () => { beforeEach(() => { jest.clearAllMocks(); for (const key of Object.keys(eventHandlers)) { @@ -98,7 +96,7 @@ describe('QonversionInternal – DeferredPurchasesListener', () => { } }); - it('setDeferredPurchasesListener subscribes to onEntitlementsUpdated', () => { + it('subscribes to native onDeferredPurchaseCompleted event', () => { const instance = new QonversionInternal(createConfig()); const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn(), @@ -106,101 +104,69 @@ describe('QonversionInternal – DeferredPurchasesListener', () => { instance.setDeferredPurchasesListener(listener); - expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalled(); + expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalled(); }); - it('does NOT fire new listener when no pending purchases tracked', () => { + it('fires listener with DeferredTransaction when native event received', () => { const instance = new QonversionInternal(createConfig()); const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; instance.setDeferredPurchasesListener(listener); - fireEvent('onEntitlementsUpdated', entitlementsUnrelated); + fireEvent('onDeferredPurchaseCompleted', sampleTransaction); - expect(listener.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); + expect(listener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + const arg = (listener.onDeferredPurchaseCompleted as jest.Mock).mock.calls[0][0]; + expect(arg).toBeInstanceOf(DeferredTransaction); + expect(arg.productId).toBe('premium_product'); + expect(arg.transactionId).toBe('txn_123'); + expect(arg.type).toBe('Subscription'); + expect(arg.value).toBe(9.99); + expect(arg.currency).toBe('USD'); }); - it('fires new listener when pending purchase product is active in entitlements update', async () => { - const instance = new QonversionInternal(createConfig()); - const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; - - instance.setDeferredPurchasesListener(listener); - - // Simulate a pending purchase - mockPendingPurchaseResult(); - await instance.purchaseWithResult(mockProduct, undefined); + it('does not fire listener when no listener is set', () => { + void new QonversionInternal(createConfig()); - // Fire entitlements update with the pending product now active - fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); - - expect(listener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + // No listener set, event handler not registered + expect(eventHandlers['onDeferredPurchaseCompleted']).toBeUndefined(); }); - it('does NOT fire new listener for unrelated entitlement update after pending purchase', async () => { + it('replaces previous listener when setting a new one', () => { const instance = new QonversionInternal(createConfig()); - const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; - - instance.setDeferredPurchasesListener(listener); + const listener1: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + const listener2: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; - // Simulate a pending purchase for "premium_product" - mockPendingPurchaseResult(); - await instance.purchaseWithResult(mockProduct, undefined); + instance.setDeferredPurchasesListener(listener1); + instance.setDeferredPurchasesListener(listener2); - // Fire entitlements update with a DIFFERENT product (not the pending one) - fireEvent('onEntitlementsUpdated', entitlementsUnrelated); + fireEvent('onDeferredPurchaseCompleted', sampleTransaction); - expect(listener.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); + expect(listener1.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); + expect(listener2.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); }); - it('removes product from tracking after deferred purchase completes (no double-fire)', async () => { + it('subscribes to onDeferredPurchaseCompleted only once', () => { const instance = new QonversionInternal(createConfig()); - const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; - - instance.setDeferredPurchasesListener(listener); - - mockPendingPurchaseResult(); - await instance.purchaseWithResult(mockProduct, undefined); + const listener1: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; + const listener2: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; - // First update: deferred purchase completes - fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); - expect(listener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + instance.setDeferredPurchasesListener(listener1); + instance.setDeferredPurchasesListener(listener2); - // Second update: same entitlements, but product already cleared from tracking - fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); - expect(listener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); // still 1 + expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); }); - it('does NOT track product when purchaseWithResult returns success', async () => { + it('deferred listener does NOT subscribe to onEntitlementsUpdated', () => { const instance = new QonversionInternal(createConfig()); const listener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; instance.setDeferredPurchasesListener(listener); - mockSuccessPurchaseResult(); - await instance.purchaseWithResult(mockProduct, undefined); - - fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); - - expect(listener.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); - }); - - it('replaces previous listener when setting a new one', async () => { - const instance = new QonversionInternal(createConfig()); - const listener1: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; - const listener2: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; - - instance.setDeferredPurchasesListener(listener1); - instance.setDeferredPurchasesListener(listener2); - - mockPendingPurchaseResult(); - await instance.purchaseWithResult(mockProduct, undefined); - fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); - - expect(listener1.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); - expect(listener2.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); + expect(RNQonversion.onEntitlementsUpdated).not.toHaveBeenCalled(); }); }); -describe('QonversionInternal – deprecated setEntitlementsUpdateListener', () => { +describe('QonversionInternal - deprecated setEntitlementsUpdateListener', () => { beforeEach(() => { jest.clearAllMocks(); for (const key of Object.keys(eventHandlers)) { @@ -217,37 +183,18 @@ describe('QonversionInternal – deprecated setEntitlementsUpdateListener', () = expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalled(); }); - it('always fires for ALL entitlement updates (no filtering)', () => { + it('fires for ALL entitlement updates', () => { const instance = new QonversionInternal(createConfig()); const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; instance.setEntitlementsUpdateListener(oldListener); - fireEvent('onEntitlementsUpdated', entitlementsUnrelated); - - expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); - }); - - it('old listener set via config still works', () => { - const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; - - const config = new QonversionConfig( - 'test_key', - LaunchMode.SUBSCRIPTION_MANAGEMENT, - Environment.SANDBOX, - EntitlementsCacheLifetime.MONTH, - oldListener, - undefined, - false, - ); - - void new QonversionInternal(config); - fireEvent('onEntitlementsUpdated', entitlementsUnrelated); + fireEvent('onEntitlementsUpdated', entitlementsPayload); expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); }); }); -describe('QonversionInternal – both listeners coexist', () => { +describe('QonversionInternal - both listeners coexist', () => { beforeEach(() => { jest.clearAllMocks(); for (const key of Object.keys(eventHandlers)) { @@ -255,7 +202,7 @@ describe('QonversionInternal – both listeners coexist', () => { } }); - it('old listener fires for all updates, new listener only for deferred', async () => { + it('both listeners fire independently from their own native events', () => { const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; const newListener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; @@ -270,23 +217,20 @@ describe('QonversionInternal – both listeners coexist', () => { newListener, ); - const instance = new QonversionInternal(config); + void new QonversionInternal(config); - // Unrelated update — old fires, new does NOT - fireEvent('onEntitlementsUpdated', entitlementsUnrelated); + // Entitlements update fires old listener only + fireEvent('onEntitlementsUpdated', entitlementsPayload); expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); expect(newListener.onDeferredPurchaseCompleted).not.toHaveBeenCalled(); - // Now make a pending purchase and fire matching update - mockPendingPurchaseResult(); - await instance.purchaseWithResult(mockProduct, undefined); - fireEvent('onEntitlementsUpdated', entitlementsWithActiveProduct); - - expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(2); // fires for all - expect(newListener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); // only deferred + // Deferred purchase fires new listener only + fireEvent('onDeferredPurchaseCompleted', sampleTransaction); + expect(oldListener.onEntitlementsUpdated).toHaveBeenCalledTimes(1); // still 1 + expect(newListener.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); }); - it('subscribes to onEntitlementsUpdated only once when both listeners set', () => { + it('subscribes to both native events independently', () => { const oldListener: EntitlementsUpdateListener = { onEntitlementsUpdated: jest.fn() }; const newListener: DeferredPurchasesListener = { onDeferredPurchaseCompleted: jest.fn() }; @@ -304,5 +248,6 @@ describe('QonversionInternal – both listeners coexist', () => { void new QonversionInternal(config); expect(RNQonversion.onEntitlementsUpdated).toHaveBeenCalledTimes(1); + expect(RNQonversion.onDeferredPurchaseCompleted).toHaveBeenCalledTimes(1); }); });