react-native-adapty
Version:
Adapty React Native SDK
308 lines (266 loc) • 9.66 kB
text/typescript
import { EmitterSubscription } from 'react-native';
import { LogContext } from '@/logger';
import type { AdaptyType } from '@/coders/parse';
import { parseCommonEvent, parsePaywallEvent } from '@/coders/parse';
import { parseOnboardingEvent } from '@/coders/parse-onboarding';
import { MockStore } from './mock-store';
import type { AdaptyMockConfig } from './types';
import { createMockPurchaseResult } from './mock-data';
import { generateId } from '@/utils/generate-id';
import { AdaptyProfileParametersCoder } from '@/coders/adapty-profile-parameters';
import { AdaptyProfileCoder } from '@/coders/adapty-profile';
type EventCallback = (...args: any[]) => void | Promise<void>;
/**
* Simple event emitter for mock events
*/
class SimpleEventEmitter {
private listeners: Map<string, Set<EventCallback>> = new Map();
addListener(event: string, callback: EventCallback): EmitterSubscription {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
return {
remove: () => {
this.listeners.get(event)?.delete(callback);
},
} as EmitterSubscription;
}
emit(event: string, ...args: any[]): void {
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.forEach(callback => {
try {
callback(...args);
} catch (error) {
const ctx = new LogContext();
const log = ctx.event({ methodName: `mock/${event}` });
log.failed({ error });
}
});
}
}
removeAllListeners(): void {
this.listeners.clear();
}
}
/**
* Mock implementation of NativeRequestHandler
* Returns mock data instead of calling native modules
*/
export class MockRequestHandler<Method extends string, Params extends string> {
private store: MockStore;
private emitter: SimpleEventEmitter;
private listeners: Set<EmitterSubscription>;
constructor(config: AdaptyMockConfig = {}) {
this.store = new MockStore(config);
this.emitter = new SimpleEventEmitter();
this.listeners = new Set();
}
/**
* Provides access to internal emitter for testing purposes only
* @internal
*/
get testEmitter() {
return this.emitter;
}
/**
* Mock request handler that returns appropriate mock data
*
* @param method - The SDK method name (e.g., 'make_purchase', 'get_paywall_products')
* @param params - JSON string containing request parameters in cross_platform.yaml format
* @param _resultType - Expected result type (not used in mock)
* @param ctx - Log context for debugging
*
* @remarks
* The `params` argument contains JSON-stringified data that follows the request format
* defined in `cross_platform.yaml`. For example, for 'make_purchase' method, it contains
* `MakePurchase.Request` structure with `product` field in `AdaptyPaywallProduct.Request`
* format (snake_case, minimal field set).
*
* @returns Promise resolving to mock data in the expected format
*/
async request<T>(
method: Method,
params: Params,
_resultType: AdaptyType,
ctx?: LogContext,
): Promise<T> {
const log = ctx?.bridge({ methodName: `mock/${method}` });
log?.start({ method, params });
try {
let result: any;
// Parse params from cross_platform.yaml format (e.g., MakePurchase.Request, GetPaywallProducts.Request)
// All fields are in snake_case as defined in the cross-platform specification
const parsedParams = JSON.parse(params as string);
switch (method) {
case 'activate':
this.store.setActivated(true);
result = undefined; // void
break;
case 'is_activated':
result = this.store.getIsActivated();
break;
case 'get_profile':
result = this.store.getProfile();
break;
case 'get_paywall':
case 'get_paywall_for_default_audience':
const paywallPlacementId = parsedParams.placement_id;
result = this.store.getPaywall(paywallPlacementId);
break;
case 'get_paywall_products':
const paywall = parsedParams.paywall;
const placementId: string =
paywall?.placement?.developer_id || 'default';
const variationId: string =
paywall?.variation_id || 'mock_variation_id';
result = this.store.getPaywallProducts(placementId, variationId);
break;
case 'get_onboarding':
case 'get_onboarding_for_default_audience':
const onboardingPlacementId = parsedParams.placement_id;
result = this.store.getOnboarding(onboardingPlacementId);
break;
case 'make_purchase':
// Extract accessLevelId from Request format (snake_case)
const productAccessLevelId = parsedParams.product.access_level_id;
const updatedProfile = this.store.makePurchase(productAccessLevelId);
result = createMockPurchaseResult(updatedProfile);
// Emit profile update event
// Event format must match cross_platform.yaml: { profile: <encoded_profile> }
setTimeout(() => {
const profileCoder = new AdaptyProfileCoder();
const encodedProfile = profileCoder.encode(updatedProfile);
this.emitter.emit(
'did_load_latest_profile',
JSON.stringify({ profile: encodedProfile }),
);
}, 50);
break;
case 'restore_purchases':
result = this.store.getProfile();
break;
case 'identify':
const customerUserId = parsedParams.customer_user_id;
this.store.identify(customerUserId);
result = undefined; // void
break;
case 'logout':
this.store.logout();
result = undefined; // void
break;
case 'update_profile':
const profileParamsCoder = new AdaptyProfileParametersCoder();
const profileParams = profileParamsCoder.decode(parsedParams.params);
this.store.updateProfile(profileParams);
result = undefined; // void
break;
case 'log_show_paywall':
case 'set_log_level':
case 'update_attribution_data':
case 'set_fallback':
case 'set_integration_identifiers':
case 'report_transaction':
case 'present_code_redemption_sheet':
case 'update_collecting_refund_data_consent':
case 'update_refund_preference':
case 'open_web_paywall':
// These methods don't return anything meaningful in mock mode
result = undefined; // void
break;
case 'create_web_paywall_url':
result = 'https://mock-web-paywall-url.adapty.io';
break;
case 'get_current_installation_status':
result = {
status: 'determined',
details: {
installTime: new Date(),
appLaunchCount: 1,
},
};
break;
// UI methods
case 'adapty_ui_create_paywall_view':
result = { id: `mock-paywall-${generateId()}` };
break;
case 'adapty_ui_create_onboarding_view':
result = { id: `mock-onboarding-${generateId()}` };
break;
case 'adapty_ui_present_paywall_view':
case 'adapty_ui_present_onboarding_view':
case 'adapty_ui_dismiss_paywall_view':
case 'adapty_ui_dismiss_onboarding_view':
case 'adapty_ui_activate':
result = undefined; // void
break;
case 'adapty_ui_show_dialog':
result = 'primary';
break;
default:
result = undefined;
}
log?.success({ result });
return result as T;
} catch (error) {
log?.success({ error });
throw error;
}
}
/**
* Add event listener for mock events
*/
addRawEventListener<
Event extends string,
Cb extends (event: any) => void | Promise<void>,
>(event: Event, cb: Cb): EmitterSubscription {
const subscription = this.emitter.addListener(event, cb);
this.listeners.add(subscription);
return subscription;
}
/**
* Add typed event listener
*/
addEventListener<Event extends string, CallbackData>(
event: Event,
cb: (this: { rawValue: any }, data: CallbackData) => void | Promise<void>,
): EmitterSubscription {
const consumeNativeCallback = (...data: string[]) => {
const ctx = new LogContext();
const log = ctx.event({ methodName: `mock/${event}` });
log.start(data);
let rawValue = null;
const args = data.map(arg => {
try {
const commonEvent = parseCommonEvent(event, arg, ctx);
if (commonEvent) return commonEvent;
try {
rawValue = JSON.parse(arg);
} catch {}
const onboardingEvent = parseOnboardingEvent(arg, ctx);
if (onboardingEvent) {
return onboardingEvent;
}
const paywallEvent = parsePaywallEvent(arg, ctx);
return paywallEvent;
} catch (error) {
log.failed({ error });
throw error;
}
});
cb.apply({ rawValue }, args as any);
};
const subscription = this.emitter.addListener(event, consumeNativeCallback);
this.listeners.add(subscription);
return subscription;
}
/**
* Remove all event listeners
*/
removeAllEventListeners(): void {
this.listeners.forEach(listener => listener.remove());
this.listeners.clear();
this.emitter.removeAllListeners();
}
}