diff --git a/README.md b/README.md index e7128346..059d6f30 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,21 @@ ![Adapty: CRM for mobile apps with subscriptions](https://adapty-portal-media-production.s3.amazonaws.com/github/adapty-schema.png) -Adapty SDK is an open-source framework that makes implementing in-app subscriptions for React Native fast and easy. It’s 100% open-source and lightweight. +Adapty SDK is an open-source framework that makes implementing in-app subscriptions for React Native fast and easy. It's 100% open-source and lightweight. + +## Table of Contents + +- [Why Adapty?](#why-adapty) +- [Getting Started](#getting-started) +- [Integrate IAPs within a few hours without server coding](#integrate-iaps-within-a-few-hours-without-server-coding) +- [Design paywalls in the no-code builder](#design-paywalls-in-the-no-code-builder) +- [Test paywalls & prices on React Native without app releases](#test-paywalls--prices-on-react-native-without-app-releases) +- [Real-time analytics for your React Native app](#real-time-analytics-for-your-react-native-app) +- [Mobile app monetization's largest community](#mobile-app-monetizations-largest-community) +- [React Native Architecture Compatibility](#react-native-architecture-compatibility) +- [Examples](#examples) +- [Contributing](#contributing) +- [License](#license) ## Why Adapty? @@ -39,6 +53,27 @@ Adapty SDK is an open-source framework that makes implementing in-app subscripti Talk to Us to Learn More +## Getting Started + +### For React Native projects: + +```sh +# using npm +npm install react-native-adapty + +# or using yarn +yarn add react-native-adapty +``` + +### For Expo projects: + +```sh +npx expo install react-native-adapty +npx expo prebuild +``` + +Read the [documentation](https://adapty.io/docs/sdk-installation-reactnative?utm_source=github&utm_medium=referral&utm_campaign=AdaptySDK-React-Native) to install and configure Adapty SDK. Set up purchases in hours instead of weeks :rocket: + ## Integrate IAPs within a few hours without server coding **Adapty handles everything, from free trials to refunds, in a simple, developer-friendly SDK.** @@ -78,28 +113,6 @@ Ask questions, participate in discussions about Adapty-related topics, become a - -## Getting Started - -### For React Native projects: - -```sh -# using npm -npm install react-native-adapty - -# or using yarn -yarn add react-native-adapty -``` - -### For Expo projects: - -```sh -npx expo install react-native-adapty -npx expo prebuild -``` - -Read the [documentation](https://adapty.io/docs/sdk-installation-reactnative?utm_source=github&utm_medium=referral&utm_campaign=AdaptySDK-React-Native) to install and configure Adapty SDK. Set up purchases in hours instead of weeks :rocket: - ## React Native Architecture Compatibility Adapty SDK is compatible with both **React Native's New Architecture** (including Turbo Modules) and the legacy architecture. @@ -110,7 +123,8 @@ Adapty SDK is compatible with both **React Native's New Architecture** (includin We provide several example applications with increasing complexity: - **[BasicExample](./examples/BasicExample/)** (React Native) – Minimal setup example showing core SDK features. -- **[FocusJournalExpo](./examples/FocusJournalExpo/)** (Expo) – Full-featured app with navigation and premium features. +- **[ExpoGoWebMock](./examples/ExpoGoWebMock/)** (Expo Go / Expo Web) – Easiest to run (works in browser with mock mode, no Adapty key required). Demonstrates mock data usage for Expo Go/Web. Includes both custom paywall and Adapty Paywall Builder. +- **[FocusJournalExpo](./examples/FocusJournalExpo/)** (Expo) – Simple app with premium features using Adapty Paywall Builder. Includes video guide. - **[AdaptyDevtools](./examples/AdaptyDevtools/)** (React Native) – DevTools and bug reporting tool. 📹 **Watch our video guide** for step-by-step integration with the Focus Journal Expo example: diff --git a/android/build.gradle b/android/build.gradle index adf93041..2a192d01 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -110,10 +110,10 @@ def getExtOrDefault(name) { def kotlin_version = getExtOrDefault('kotlinVersion') dependencies { - implementation platform('io.adapty:adapty-bom:3.15.1') + implementation platform('io.adapty:adapty-bom:3.15.2') implementation 'io.adapty:android-sdk' implementation 'io.adapty:android-ui' - implementation 'io.adapty.internal:crossplatform:3.15.2' + implementation 'io.adapty.internal:crossplatform:3.15.3' // Compatible with older and newer RN //noinspection GradleDynamicVersion diff --git a/cross_platform.yaml b/cross_platform.yaml index 2d3d15e5..1ad5b9a5 100644 --- a/cross_platform.yaml +++ b/cross_platform.yaml @@ -1,5 +1,5 @@ $schema: "https://json-schema.org/draft/2020-12/schema" -$id: "https://adapty.io/crossPlatform/3.15.1/schema" +$id: "https://adapty.io/crossPlatform/3.15.2/schema" title: "Cross Platform Format" $requests: @@ -412,6 +412,7 @@ $requests: properties: paywall: { $ref: "#/$defs/AdaptyPaywall" } + OpenWebPaywall.Response: #response type: object oneOf: @@ -931,6 +932,7 @@ $defs: google_enable_pending_prepaid_plans: { type: boolean, default: false } google_local_access_level_allowed: { type: boolean, default: false } ip_address_collection_disabled: { type: boolean, default: false } + clear_data_on_backup: { type: boolean, default: false } server_cluster: { type: string, enum: ["default", "eu", "cn"] } backend_proxy_host: { type: string } backend_proxy_port: { type: integer } diff --git a/examples/AdaptyDevtools/ios/Podfile.lock b/examples/AdaptyDevtools/ios/Podfile.lock index 270c1abc..1885b832 100644 --- a/examples/AdaptyDevtools/ios/Podfile.lock +++ b/examples/AdaptyDevtools/ios/Podfile.lock @@ -1,19 +1,19 @@ PODS: - - Adapty (3.15.1): - - AdaptyLogger (= 3.15.1) - - AdaptyUIBuilder (= 3.15.1) - - AdaptyLogger (3.15.1) - - AdaptyPlugin (3.15.1): - - Adapty (= 3.15.1) - - AdaptyLogger (= 3.15.1) - - AdaptyUI (= 3.15.1) - - AdaptyUIBuilder (= 3.15.1) - - AdaptyUI (3.15.1): - - Adapty (= 3.15.1) - - AdaptyLogger (= 3.15.1) - - AdaptyUIBuilder (= 3.15.1) - - AdaptyUIBuilder (3.15.1): - - AdaptyLogger (= 3.15.1) + - Adapty (3.15.3): + - AdaptyLogger (= 3.15.3) + - AdaptyUIBuilder (= 3.15.3) + - AdaptyLogger (3.15.3) + - AdaptyPlugin (3.15.3): + - Adapty (= 3.15.3) + - AdaptyLogger (= 3.15.3) + - AdaptyUI (= 3.15.3) + - AdaptyUIBuilder (= 3.15.3) + - AdaptyUI (3.15.3): + - Adapty (= 3.15.3) + - AdaptyLogger (= 3.15.3) + - AdaptyUIBuilder (= 3.15.3) + - AdaptyUIBuilder (3.15.3): + - AdaptyLogger (= 3.15.3) - boost (1.84.0) - DoubleConversion (1.1.6) - fast_float (8.0.0) @@ -1663,10 +1663,10 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - react-native-adapty-sdk (3.15.0): - - Adapty (= 3.15.1) - - AdaptyPlugin (= 3.15.1) - - AdaptyUI (= 3.15.1) + - react-native-adapty-sdk (3.15.1): + - Adapty (= 3.15.3) + - AdaptyPlugin (= 3.15.3) + - AdaptyUI (= 3.15.3) - React - react-native-safe-area-context (5.4.1): - boost @@ -2538,11 +2538,11 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - Adapty: bb2cc10a238b5d53025a0a969bc2a7ad6e9a48f5 - AdaptyLogger: 2d1a3cc8b8dee29dc171df753d6b6d45332bd399 - AdaptyPlugin: 79f4d39bcaa84ad3755fe0f0a07148abca2af4ed - AdaptyUI: 56ff8ed7be49cbff9cdc29ddde230de60e115d4f - AdaptyUIBuilder: 3204524ee377de2eddec478d51c3cf1f1e83ef1c + Adapty: 9d5c8378ddf71747e5182ae9dd3426b2a4dc274a + AdaptyLogger: 8a58745fe27ac991d2b2f05de57bfa9d932383ca + AdaptyPlugin: d832e606563b2cc3e77dbc4ca23e64725a3b21c5 + AdaptyUI: 7b8515dade06d8a046826f024940c009bedb5f00 + AdaptyUIBuilder: e94ea218d936f58f13123fda19f3c13fba5cdb7d boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 @@ -2583,7 +2583,7 @@ SPEC CHECKSUMS: React-logger: b69e65dc60f768e5509ac0cc27a360124bf70478 React-Mapbuffer: b48f9f3311fd0ec0f7a5dc39d707eff521fb5f38 React-microtasksnativemodule: d8568d0485a350c720c061ae835e09fc88c28715 - react-native-adapty-sdk: 39e2fb1607910e7d719e291281516864846792f1 + react-native-adapty-sdk: 765bc4b65a425b4f77e370d9b8e33ddcd046c578 react-native-safe-area-context: 6775aa9089fa84b77abd7ebdcf45e224a2a2ad3e React-NativeModulesApple: f10596688a03af66804cfbe61792be24a7888da8 React-oscompat: 7c0a341cc31e350da71ddf2e46de0a845d1d1626 diff --git a/package.json b/package.json index d66ecca9..9ead9726 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package", "name": "react-native-adapty", - "version": "3.15.0", + "version": "3.15.1", "description": "Adapty React Native SDK", "license": "MIT", "author": "Adapty team ", diff --git a/react-native-adapty-sdk.podspec b/react-native-adapty-sdk.podspec index bc404dc4..e60571bc 100644 --- a/react-native-adapty-sdk.podspec +++ b/react-native-adapty-sdk.podspec @@ -19,9 +19,9 @@ Pod::Spec.new do |s| s.resources = "ios/**/*.{plist}" s.requires_arc = true - s.dependency "Adapty", "3.15.1" - s.dependency "AdaptyUI", "3.15.1" - s.dependency "AdaptyPlugin", "3.15.1" + s.dependency "Adapty", "3.15.3" + s.dependency "AdaptyUI", "3.15.3" + s.dependency "AdaptyPlugin", "3.15.3" s.dependency "React" end diff --git a/src/__tests__/integration/adapty-handler/adapty-handler-bridge-event-samples.ts b/src/__tests__/integration/adapty-handler/adapty-handler-bridge-event-samples.ts new file mode 100644 index 00000000..d70afc55 --- /dev/null +++ b/src/__tests__/integration/adapty-handler/adapty-handler-bridge-event-samples.ts @@ -0,0 +1,86 @@ +/** + * Bridge event samples for general Adapty handler events + * + * Real event data extracted from native logs for accurate testing. + * + * Use these samples for integration tests to verify event handling. + */ + +/** + * Sample for Event.DidLoadLatestProfile with active premium subscription + * @see cross_platform.yaml#/$events/Event.DidLoadLatestProfile + */ +export const PROFILE_DID_LOAD_LATEST_WITH_PREMIUM = { + id: 'did_load_latest_profile', + profile: { + paid_access_levels: { + premium: { + activated_at: '2025-12-26T13:36:09.931000+0000', + expires_at: '2025-12-26T13:41:09.549000+0000', + id: 'premium', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + renewed_at: '2025-12-26T13:36:09.931000+0000', + store: 'play_store', + vendor_product_id: 'weekly.premium.599', + will_renew: true, + }, + }, + custom_attributes: {}, + is_test_user: false, + non_subscriptions: {}, + profile_id: 'cbdabead-697c-4804-9ea5-7ccaa83411c7', + subscriptions: { + 'weekly.premium.599': { + activated_at: '2025-12-26T13:36:09.931000+0000', + expires_at: '2025-12-26T13:41:09.549000+0000', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + is_sandbox: true, + renewed_at: '2025-12-26T13:36:09.931000+0000', + store: 'play_store', + vendor_original_transaction_id: 'GPA.3338-3241-1006-23335', + vendor_product_id: 'weekly.premium.599', + vendor_transaction_id: 'GPA.3338-3241-1006-23335', + will_renew: true, + }, + }, + segment_hash: 'not implemented', + timestamp: -1, + }, +} as const; + +/** + * Sample for Event.DidLoadLatestProfile with empty profile (no subscriptions) + * @see cross_platform.yaml#/$events/Event.DidLoadLatestProfile + */ +export const PROFILE_DID_LOAD_LATEST_EMPTY = { + id: 'did_load_latest_profile', + profile: { + profile_id: '8b79ec26-3f3d-482c-99e8-ec745710ef59', + customer_user_id: null, + segment_hash: + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + paid_access_levels: {}, + subscriptions: {}, + non_subscriptions: {}, + }, +} as const; + +/** + * Sample for Event.OnInstallationDetailsSuccess + * @see cross_platform.yaml#/$events/Event.OnInstallationDetailsSuccess + */ +export const INSTALLATION_DETAILS_SUCCESS = { + id: 'on_installation_details_success', + details: { + app_launch_count: 8, + payload: '{}', + install_time: '2025-12-16T12:08:41.041Z', + install_id: 'some-install-id', + }, +} as const; diff --git a/src/__tests__/integration/ui/bridge-event-samples.ts b/src/__tests__/integration/ui/bridge-event-samples.ts deleted file mode 100644 index 50746ef8..00000000 --- a/src/__tests__/integration/ui/bridge-event-samples.ts +++ /dev/null @@ -1,430 +0,0 @@ -/** - * Bridge event samples in native format (snake_case) - * - * These samples represent events as they come from the native module. - * Synthetic examples created based on cross_platform.yaml schema for types not present in logs. - * - * Use these samples for integration tests to verify event handling. - */ - -export const ONBOARDING_ANALYTICS_ONBOARDING_STARTED = { - id: 'onboarding_on_analytics_action', - view: { - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'bGn6r0Fo', - screen_index: 0, - total_screens: 18, - }, - event: { - name: 'onboarding_started', - }, -} as const; - -export const ONBOARDING_ANALYTICS_SCREEN_PRESENTED = { - id: 'onboarding_on_analytics_action', - view: { - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'bGn6r0Fo', - screen_index: 0, - total_screens: 18, - }, - event: { - name: 'screen_presented', - }, -} as const; - -export const ONBOARDING_ANALYTICS_SECOND_SCREEN_PRESENTED = { - id: 'onboarding_on_analytics_action', - view: { - placement_id: 'test_stas0', - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'ryPxczcL', - screen_index: 1, - total_screens: 18, - }, - event: { - name: 'second_screen_presented', - }, -} as const; - -export const ONBOARDING_ANALYTICS_NAVIGATION_FAILED = { - id: 'onboarding_on_analytics_action', - view: { - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - placement_id: 'test_stas0', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'X19x4kXO', - screen_index: 17, - total_screens: 18, - }, - event: { - name: 'navigation_failed', - }, -} as const; - -export const ONBOARDING_STATE_UPDATED_TEXT_INPUT = { - id: 'onboarding_on_state_updated_action', - view: { - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'any', - screen_index: 11, - total_screens: 18, - }, - action: { - element_id: 'name', - element_type: 'input', - value: { - type: 'text', - value: 'Test-nick', - }, - }, -} as const; - -export const ONBOARDING_STATE_UPDATED_EMAIL_INPUT = { - id: 'onboarding_on_state_updated_action', - view: { - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'any', - screen_index: 12, - total_screens: 18, - }, - action: { - element_id: 'email', - element_type: 'input', - value: { - type: 'email', - value: 'test@example.com', - }, - }, -} as const; - -export const ONBOARDING_STATE_UPDATED_NUMBER_INPUT = { - id: 'onboarding_on_state_updated_action', - view: { - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'any', - screen_index: 13, - total_screens: 18, - }, - action: { - element_id: 'age', - element_type: 'input', - value: { - type: 'number', - value: 25, - }, - }, -} as const; - -export const ONBOARDING_STATE_UPDATED_SELECT_OPTION = { - id: 'onboarding_on_state_updated_action', - view: { - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'any', - screen_index: 4, - total_screens: 18, - }, - action: { - element_id: 'experience_level', - element_type: 'select', - value: { - id: 'intermediate', - value: 'intermediate', - label: 'Intermediate', - }, - }, -} as const; - -export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_SINGLE = { - id: 'onboarding_on_state_updated_action', - view: { - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - placement_id: 'test_stas0', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'S1Z2BFFy', - screen_index: 5, - total_screens: 18, - }, - action: { - value: [ - { - id: 'QmdFI', - value: 'skill-acquisition', - label: 'Skill Acquisition', - }, - ], - element_id: 'goal', - element_type: 'multi_select', - }, -} as const; - -export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_MULTIPLE = { - id: 'onboarding_on_state_updated_action', - view: { - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - placement_id: 'test_stas0', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'S1Z2BFFy', - screen_index: 5, - total_screens: 18, - }, - action: { - value: [ - { - id: 'QmdFI', - value: 'skill-acquisition', - label: 'Skill Acquisition', - }, - { - id: 'abc123', - value: 'productivity', - label: 'Productivity', - }, - ], - element_id: 'goal', - element_type: 'multi_select', - }, -} as const; - -export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_EMPTY = { - id: 'onboarding_on_state_updated_action', - view: { - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - placement_id: 'test_stas0', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'S1Z2BFFy', - screen_index: 5, - total_screens: 18, - }, - action: { - value: [], - element_id: 'goal', - element_type: 'multi_select', - }, -} as const; - -export const ONBOARDING_STATE_UPDATED_DATE_PICKER_FULL = { - id: 'onboarding_on_state_updated_action', - view: { - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'any', - screen_index: 14, - total_screens: 18, - }, - action: { - element_id: 'birth_date', - element_type: 'date_picker', - value: { - day: 15, - month: 6, - year: 1990, - }, - }, -} as const; - -export const ONBOARDING_STATE_UPDATED_DATE_PICKER_PARTIAL = { - id: 'onboarding_on_state_updated_action', - view: { - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', - }, - meta: { - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - screen_cid: 'any', - screen_index: 14, - total_screens: 18, - }, - action: { - element_id: 'birth_year', - element_type: 'date_picker', - value: { - year: 1995, - }, - }, -} as const; - -export const ONBOARDING_DID_FINISH_LOADING = { - id: 'onboarding_did_finish_loading', - view: { - placement_id: 'test_stas0', - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - }, - meta: { - screen_cid: 'bGn6r0Fo', - screen_index: 0, - onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - total_screens: 18, - }, -} as const; - -export const ONBOARDING_PAYWALL_ACTION = { - id: 'onboarding_on_paywall_action', - view: { - id: '00176648-6F60-44E8-999B-6547130D8AED', - variation_id: '2c1390e1-e195-4f59-8fa0-5830e5d6f10b', - placement_id: 'test_anna0', - }, - meta: { - onboarding_id: 'b0974453-633a-419a-870f-4f1f4bb97db1', - screen_cid: '9p61aqIB', - screen_index: 2, - total_screens: 3, - }, - action_id: 'test_anna2', -} as const; - -export const ONBOARDING_CLOSE_ACTION = { - id: 'onboarding_on_close_action', - view: { - variation_id: '2c1390e1-e195-4f59-8fa0-5830e5d6f10b', - placement_id: 'test_anna0', - id: '5F64F31E-9BC7-4733-8BDE-A546088BDE4E', - }, - meta: { - onboarding_id: 'b0974453-633a-419a-870f-4f1f4bb97db1', - screen_cid: '9p61aqIB', - screen_index: 2, - total_screens: 3, - }, - action_id: '', -} as const; - -export const ONBOARDING_CUSTOM_ACTION = { - id: 'onboarding_on_custom_action', - view: { - placement_id: 'test_anna0', - variation_id: '2c1390e1-e195-4f59-8fa0-5830e5d6f10b', - id: '5F64F31E-9BC7-4733-8BDE-A546088BDE4E', - }, - meta: { - onboarding_id: 'b0974453-633a-419a-870f-4f1f4bb97db1', - screen_cid: '9p61aqIB', - screen_index: 2, - total_screens: 3, - }, - action_id: 'id123', -} as const; - -export const ONBOARDING_ERROR_EVENT = { - id: 'onboarding_did_fail_with_error', - view: { - variation_id: '2c1390e1-e195-4f59-8fa0-5830e5d6f10b', - placement_id: 'test_anna0', - id: '4C98DF36-8A6F-4CA4-8545-065DD2C369AE', - }, - error: { - adaptyCode: 4200, - message: - 'AdaptyUIError.webKit (Code: 4200): Internal WebKit error occurred - Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."', - detail: - 'An internal WebKit error occurred: Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."', - }, -} as const; - -export const PROFILE_DID_LOAD_LATEST_PROFILE = { - id: 'did_load_latest_profile', - profile: { - profile_id: '8b79ec26-3f3d-482c-99e8-ec745710ef59', - customer_user_id: null, - segment_hash: - 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', - paid_access_levels: {}, - subscriptions: {}, - non_subscriptions: {}, - }, -} as const; - -export const INSTALLATION_DETAILS_SUCCESS = { - id: 'on_installation_details_success', - details: { - app_launch_count: 8, - payload: '{}', - install_time: '2025-12-16T12:08:41.041Z', - install_id: 'some-install-id', - }, -} as const; - -/** - * All event types collected from logs - */ -export const EVENT_TYPES = { - ONBOARDING_ANALYTICS: 'onboarding_on_analytics_action', - ONBOARDING_STATE_UPDATED: 'onboarding_on_state_updated_action', - ONBOARDING_FINISHED_LOADING: 'onboarding_did_finish_loading', - ONBOARDING_PAYWALL_ACTION: 'onboarding_on_paywall_action', - ONBOARDING_CLOSE_ACTION: 'onboarding_on_close_action', - ONBOARDING_CUSTOM_ACTION: 'onboarding_on_custom_action', - ONBOARDING_ERROR: 'onboarding_did_fail_with_error', - PROFILE_LOADED: 'did_load_latest_profile', - INSTALLATION_DETAILS: 'on_installation_details_success', -} as const; - -/** - * View metadata that appears in all onboarding events - */ -export const COMMON_VIEW_DATA = { - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - placement_id: 'test_stas0', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', -} as const; - -/** - * Onboarding metadata - */ -export const COMMON_ONBOARDING_META = { - onboardingId: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - totalScreens: 18, -} as const; diff --git a/src/__tests__/integration/ui/event-emitter.utils.ts b/src/__tests__/integration/ui/onboarding/event-emitter.utils.ts similarity index 100% rename from src/__tests__/integration/ui/event-emitter.utils.ts rename to src/__tests__/integration/ui/onboarding/event-emitter.utils.ts diff --git a/src/__tests__/integration/ui/onboarding-bridge-event-samples.ts b/src/__tests__/integration/ui/onboarding/onboarding-bridge-event-samples.ts similarity index 82% rename from src/__tests__/integration/ui/onboarding-bridge-event-samples.ts rename to src/__tests__/integration/ui/onboarding/onboarding-bridge-event-samples.ts index fb1c1d17..196235b3 100644 --- a/src/__tests__/integration/ui/onboarding-bridge-event-samples.ts +++ b/src/__tests__/integration/ui/onboarding/onboarding-bridge-event-samples.ts @@ -1,13 +1,6 @@ /** * Bridge event samples in native format (snake_case) * - * These samples represent events as they come from the native module. - * Real examples collected from device logs in: - * - native-event-data.json (session 1) - * - native-event-data-2.json (session 2) - * - native-event-data-3.json (session 3 - includes onClose and onCustom events) - * - native-event-data-4.json (session 4 - includes onError event) - * * Synthetic examples created based on cross_platform.yaml schema for types not present in logs. * * Use these samples for integration tests to verify event handling. @@ -22,7 +15,7 @@ export const ONBOARDING_ANALYTICS_ONBOARDING_STARTED = { view: { id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -44,7 +37,7 @@ export const ONBOARDING_ANALYTICS_SCREEN_PRESENTED = { view: { id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -66,7 +59,7 @@ export const ONBOARDING_ANALYTICS_WITH_ELEMENT_ID = { view: { id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -87,7 +80,7 @@ export const ONBOARDING_ANALYTICS_WITH_ELEMENT_ID = { export const ONBOARDING_ANALYTICS_SECOND_SCREEN_PRESENTED = { id: 'onboarding_on_analytics_action', view: { - placement_id: 'test_stas0', + placement_id: 'test_placement', id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', }, @@ -110,7 +103,7 @@ export const ONBOARDING_ANALYTICS_NAVIGATION_FAILED = { id: 'onboarding_on_analytics_action', view: { id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - placement_id: 'test_stas0', + placement_id: 'test_placement', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', }, meta: { @@ -133,7 +126,7 @@ export const ONBOARDING_STATE_UPDATED_TEXT_INPUT = { view: { id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -160,7 +153,7 @@ export const ONBOARDING_STATE_UPDATED_EMAIL_INPUT = { view: { id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -187,7 +180,7 @@ export const ONBOARDING_STATE_UPDATED_NUMBER_INPUT = { view: { id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -214,7 +207,7 @@ export const ONBOARDING_STATE_UPDATED_SELECT_OPTION = { view: { id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -242,7 +235,7 @@ export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_SINGLE = { view: { variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -272,7 +265,7 @@ export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_MULTIPLE = { view: { variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -307,7 +300,7 @@ export const ONBOARDING_STATE_UPDATED_MULTI_SELECT_EMPTY = { view: { variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -331,7 +324,7 @@ export const ONBOARDING_STATE_UPDATED_DATE_PICKER_FULL = { view: { id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -359,7 +352,7 @@ export const ONBOARDING_STATE_UPDATED_DATE_PICKER_PARTIAL = { view: { id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', - placement_id: 'test_stas0', + placement_id: 'test_placement', }, meta: { onboarding_id: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', @@ -383,7 +376,7 @@ export const ONBOARDING_STATE_UPDATED_DATE_PICKER_PARTIAL = { export const ONBOARDING_DID_FINISH_LOADING = { id: 'onboarding_did_finish_loading', view: { - placement_id: 'test_stas0', + placement_id: 'test_placement', id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', }, @@ -474,66 +467,3 @@ export const ONBOARDING_ERROR_EVENT = { 'An internal WebKit error occurred: Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."', }, } as const; - -/** - * Sample for Event.DidLoadLatestProfile - * @see cross_platform.yaml#/$events/Event.DidLoadLatestProfile - */ -export const PROFILE_DID_LOAD_LATEST_PROFILE = { - id: 'did_load_latest_profile', - profile: { - profile_id: '8b79ec26-3f3d-482c-99e8-ec745710ef59', - customer_user_id: null, - segment_hash: - 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', - paid_access_levels: {}, - subscriptions: {}, - non_subscriptions: {}, - }, -} as const; - -/** - * Sample for Event.OnInstallationDetailsSuccess - * @see cross_platform.yaml#/$events/Event.OnInstallationDetailsSuccess - */ -export const INSTALLATION_DETAILS_SUCCESS = { - id: 'on_installation_details_success', - details: { - app_launch_count: 8, - payload: '{}', - install_time: '2025-12-16T12:08:41.041Z', - install_id: 'some-install-id', - }, -} as const; - -/** - * All event types collected from logs - */ -export const EVENT_TYPES = { - ONBOARDING_ANALYTICS: 'onboarding_on_analytics_action', - ONBOARDING_STATE_UPDATED: 'onboarding_on_state_updated_action', - ONBOARDING_FINISHED_LOADING: 'onboarding_did_finish_loading', - ONBOARDING_PAYWALL_ACTION: 'onboarding_on_paywall_action', - ONBOARDING_CLOSE_ACTION: 'onboarding_on_close_action', - ONBOARDING_CUSTOM_ACTION: 'onboarding_on_custom_action', - ONBOARDING_ERROR: 'onboarding_did_fail_with_error', - PROFILE_LOADED: 'did_load_latest_profile', - INSTALLATION_DETAILS: 'on_installation_details_success', -} as const; - -/** - * View metadata that appears in all onboarding events - */ -export const COMMON_VIEW_DATA = { - id: 'C2ECBFB4-5ADA-4E42-B129-49A7977175F3', - placement_id: 'test_stas0', - variation_id: 'd7e60b9e-453a-42a1-8e80-145b3740cbbb', -} as const; - -/** - * Onboarding metadata - */ -export const COMMON_ONBOARDING_META = { - onboardingId: '5e8e68b1-2696-4a5d-8069-4a5f9f4ac022', - totalScreens: 18, -} as const; diff --git a/src/__tests__/integration/ui/onboarding-view-controller-events.test.ts b/src/__tests__/integration/ui/onboarding/onboarding-view-controller-events.test.ts similarity index 99% rename from src/__tests__/integration/ui/onboarding-view-controller-events.test.ts rename to src/__tests__/integration/ui/onboarding/onboarding-view-controller-events.test.ts index 10232220..2fa5004b 100644 --- a/src/__tests__/integration/ui/onboarding-view-controller-events.test.ts +++ b/src/__tests__/integration/ui/onboarding/onboarding-view-controller-events.test.ts @@ -6,7 +6,7 @@ import { OnboardingEventHandlers } from '@/ui/types'; import { createOnboardingViewController, cleanupOnboardingViewController, -} from './setup.utils'; +} from '../setup.utils'; import { emitOnboardingCloseEvent, emitOnboardingAnalyticsEvent, diff --git a/src/__tests__/integration/ui/paywall/android-paywall-bridge-event-samples.ts b/src/__tests__/integration/ui/paywall/android-paywall-bridge-event-samples.ts new file mode 100644 index 00000000..e0f66c9d --- /dev/null +++ b/src/__tests__/integration/ui/paywall/android-paywall-bridge-event-samples.ts @@ -0,0 +1,320 @@ +/** + * Android-specific bridge event samples in native format (snake_case) + * + * Real Android event data extracted from native logs for accurate testing. + * iOS-specific fields have been removed, Android-specific fields are included. + * + * Use these samples for integration tests to verify Android-specific event handling. + */ + +/** + * Sample for PaywallViewEvent.DidUserAction with system_back action (Android) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidUserAction + */ +export const ANDROID_PAYWALL_USER_ACTION_SYSTEM_BACK = { + view: { + id: '99cc6779-cf80-4fca-ae3c-9d7a7bca0fed', + placement_id: 'AdaptyRnSdkExample1', + variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c', + }, + action: { + type: 'system_back', + }, + id: 'paywall_view_did_perform_action', +} as const; + +/** + * Sample for PaywallViewEvent.DidPurchase with successful purchase result (Android) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidPurchase + */ +export const ANDROID_PAYWALL_PURCHASE_COMPLETED_SUCCESS = { + view: { + id: '88df97f8-ca94-43a4-bd4a-1749a89988e8', + placement_id: 'AdaptyRnSdkExample1', + variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c', + }, + product: { + access_level_id: 'premium', + localized_description: 'sdfg', + localized_title: 'sdfg', + payload_data: + 'eyJjdXJyZW5jeV9jb2RlIjoiRVVSIiwicHJpY2VfYW1vdW50X21pY3JvcyI6MTE5OTAwMDAsInN1\nYnNjcmlwdGlvbl9kYXRhIjp7ImJhc2VfcGxhbl9pZCI6IndlZWtseS1wcmVtaXVtLTU5OS1iYXNl\nIiwib2ZmZXJfaWQiOm51bGx9LCJ0eXBlIjoic3VicyJ9\n', + paywall_ab_test_name: 'AdaptyRnSdkExample1', + paywall_name: 'AdaptyRnSdkExample1', + price: { + amount: 11.99, + currency_code: 'EUR', + currency_symbol: '€', + localized_string: '€11.99', + }, + product_type: 'weekly', + subscription: { + base_plan_id: 'weekly-premium-599-base', + localized_period: '1 week', + offer_id: 'intro-pay-up-front-weekly-premium', + offer_tags: [], + renewal_type: 'autorenewable', + period: { + number_of_units: 1, + unit: 'week', + }, + }, + paywall_variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c', + vendor_product_id: 'weekly.premium.599', + web_purchase_url: + 'http://paywalls-14c3d623-2f3a-455a-aa86-ef83dff6913b.fnlfx.com/trest2', + paywall_product_index: 0, + adapty_product_id: 'b136422f-8153-402a-afbb-986929c68f6a', + }, + purchased_result: { + type: 'success', + google_purchase_token: + 'abcdefghijklmnopqrs.AO-J1Oy0BKdJa02G4rK5G7jxhSbdajFmsy3FSHTqLUwE1rApPKMSJaY_gZg6aIVgxPO10_GRD94ZCSKvoAp3EkqDLRLQ45W7-Q', + profile: { + paid_access_levels: { + premium: { + activated_at: '2025-12-26T13:36:09.931000+0000', + expires_at: '2025-12-26T13:41:09.549000+0000', + id: 'premium', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + renewed_at: '2025-12-26T13:36:09.931000+0000', + store: 'play_store', + vendor_product_id: 'weekly.premium.599', + will_renew: true, + }, + }, + custom_attributes: {}, + is_test_user: false, + non_subscriptions: {}, + profile_id: 'cbdabead-697c-4804-9ea5-7ccaa83411c7', + subscriptions: { + 'weekly.premium.599': { + activated_at: '2025-12-26T13:36:09.931000+0000', + expires_at: '2025-12-26T13:41:09.549000+0000', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + is_sandbox: true, + renewed_at: '2025-12-26T13:36:09.931000+0000', + store: 'play_store', + vendor_original_transaction_id: 'GPA.3338-3241-1006-23335', + vendor_product_id: 'weekly.premium.599', + vendor_transaction_id: 'GPA.3338-3241-1006-23335', + will_renew: true, + }, + }, + segment_hash: 'not implemented', + timestamp: -1, + }, + }, + id: 'paywall_view_did_finish_purchase', +} as const; + +/** + * Sample for PaywallViewEvent.DidFailPurchase (Android) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailPurchase + */ +export const ANDROID_PAYWALL_PURCHASE_FAILED = { + view: { + id: '2064bb24-39e4-4c06-a9aa-4417357edfb4', + placement_id: 'test_placement', + variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + }, + product: { + access_level_id: 'premium', + localized_description: 'Description', + localized_title: 'Title', + payload_data: + 'eyJjdXJyZW5jeV9jb2RlIjoiRVVSIiwicHJpY2VfYW1vdW50X21pY3JvcyI6MTM5OTAwMDAsInN1\nYmNjcmlwdGlvbl9kYXRhIjp7ImJhc2VfcGxhbl9pZCI6Im1vbnRobHktcHJlbWl1bS05OTktYmFz\nZSIsIm9mZmVyX2lkIjpudWxsfSwidHlwZSI6InN1YnMifQ==\n', + paywall_ab_test_name: 'test_restore_button', + paywall_name: 'test_restore_button', + price: { + amount: 13.99, + currency_code: 'EUR', + currency_symbol: '€', + localized_string: '€13.99', + }, + product_type: 'monthly', + subscription: { + base_plan_id: 'monthly-premium-999-base', + localized_period: '1 month', + offer_tags: [], + renewal_type: 'autorenewable', + period: { + number_of_units: 1, + unit: 'month', + }, + }, + paywall_variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + vendor_product_id: 'monthly.premium.999', + paywall_product_index: 0, + adapty_product_id: 'ac281b85-9294-4109-b9f1-4ab66b52d263', + }, + error: { + adapty_code: 103, + message: 'Play Market request failed on purchases updated: responseCode=3', + }, + id: 'paywall_view_did_fail_purchase', +} as const; + +/** + * Sample for PaywallViewEvent.WillPurchase (Android) + * @see cross_platform.yaml#/$events/PaywallViewEvent.WillPurchase + */ +export const ANDROID_PAYWALL_PURCHASE_STARTED = { + product: { + subscription: { + base_plan_id: 'yearly-premium-6999-base', + localized_period: '1 year', + offer_tags: [], + renewal_type: 'autorenewable', + period: { + unit: 'year', + number_of_units: 1, + }, + }, + region_code: 'US', + access_level_id: 'premium', + localized_title: '1 Year Premium', + vendor_product_id: 'yearly.premium.6999', + localized_description: '1 Year Premium Description', + paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + price: { + currency_code: 'USD', + currency_symbol: '$', + localized_string: '$69.99', + amount: 69.99, + }, + paywall_product_index: 1, + paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + product_type: 'annual', + paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd', + }, + view: { + variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + placement_id: '3968c273-f247-4b9f-bd90-305be39d6414', + id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF', + }, + id: 'paywall_view_did_start_purchase', +} as const; + +/** + * Sample for PaywallViewEvent.DidRestorePurchase with successful restore (Android) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidRestorePurchase + */ +export const ANDROID_PAYWALL_RESTORE_COMPLETED_SUCCESS = { + view: { + id: '3980be37-7a25-4c38-aace-68ee46b2927c', + placement_id: 'test_placement', + variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + }, + profile: { + paid_access_levels: { + premium: { + activated_at: '2025-12-26T14:15:01.977000+0000', + active_introductory_offer_type: 'free_trial', + expires_at: '2025-12-26T14:18:01.719000+0000', + id: 'premium', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + offer_id: 'intro-free-trial-yearly', + renewed_at: '2025-12-26T14:15:01.977000+0000', + store: 'play_store', + vendor_product_id: 'yearly.premium.6999', + will_renew: true, + }, + }, + custom_attributes: {}, + is_test_user: false, + non_subscriptions: {}, + profile_id: 'd200c008-13fd-4557-9c51-cff73b45a7f2', + subscriptions: { + 'yearly.premium.6999': { + activated_at: '2025-12-26T14:15:01.977000+0000', + active_introductory_offer_type: 'free_trial', + expires_at: '2025-12-26T14:18:01.719000+0000', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + is_sandbox: true, + offer_id: 'intro-free-trial-yearly', + renewed_at: '2025-12-26T14:15:01.977000+0000', + store: 'play_store', + vendor_original_transaction_id: 'GPA.3372-6866-7337-08302', + vendor_product_id: 'yearly.premium.6999', + vendor_transaction_id: 'GPA.3372-6866-7337-08302', + will_renew: true, + }, + 'weekly.premium.599': { + activated_at: '2025-12-26T13:36:09.931000+0000', + cancellation_reason: 'unknown', + expires_at: '2025-12-26T13:41:09.549000+0000', + is_active: false, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + is_sandbox: true, + renewed_at: '2025-12-26T13:36:09.931000+0000', + store: 'play_store', + vendor_original_transaction_id: 'GPA.3338-3241-1006-23335', + vendor_product_id: 'weekly.premium.599', + vendor_transaction_id: 'GPA.3338-3241-1006-23335', + will_renew: false, + }, + }, + segment_hash: 'not implemented', + timestamp: -1, + }, + id: 'paywall_view_did_finish_restore', +} as const; + +/** + * Sample for PaywallViewEvent.DidFinishWebPaymentNavigation (Android) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFinishWebPaymentNavigation + */ +export const ANDROID_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED = { + view: { + id: '2442A0E9-FB7F-4369-87BB-61C80222AFA1', + variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48', + placement_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48', + }, + product: { + region_code: 'US', + product_type: 'semiannual', + paywall_name: 'rt.web', + vendor_product_id: 'sixmonth.premium.999', + localized_title: 'Six Months Premium', + web_purchase_url: 'https://example.com', + access_level_id: 'premium', + paywall_ab_test_name: 'rt.web', + subscription: { + period: { + unit: 'month', + number_of_units: 6, + }, + base_plan_id: 'sixmonth-premium-999-base', + localized_period: '6 months', + offer_tags: [], + renewal_type: 'autorenewable', + }, + localized_description: 'Six Months Premium Description', + adapty_product_id: '0f2e86da-4d6f-435e-bc8f-9f2d1c265a27', + paywall_variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48', + price: { + currency_symbol: '$', + localized_string: '$14.99', + currency_code: 'USD', + amount: 14.99, + }, + paywall_product_index: 0, + }, + id: 'paywall_view_did_finish_web_payment_navigation', +} as const; diff --git a/src/__tests__/integration/ui/paywall/android-paywall-view-controller-events.test.ts b/src/__tests__/integration/ui/paywall/android-paywall-view-controller-events.test.ts new file mode 100644 index 00000000..008d00ab --- /dev/null +++ b/src/__tests__/integration/ui/paywall/android-paywall-view-controller-events.test.ts @@ -0,0 +1,398 @@ +import { Platform } from 'react-native'; +import { Adapty } from '@/adapty-handler'; +import { AdaptyError } from '@/adapty-error'; +import { ViewController } from '@/ui/view-controller'; +import { EventHandlers } from '@/ui/types'; +import type { AdaptySubscription, AdaptyAccessLevel } from '@/types'; +import { + createPaywallViewController, + cleanupPaywallViewController, +} from '../setup.utils'; +import { + emitPaywallUserActionEvent, + emitPaywallPurchaseStartedEvent, + emitPaywallPurchaseCompletedEvent, + emitPaywallPurchaseFailedEvent, + emitPaywallRestoreCompletedEvent, + emitPaywallWebPaymentNavigationFinishedEvent, +} from './paywall-event-emitter.utils'; +import { + ANDROID_PAYWALL_USER_ACTION_SYSTEM_BACK, + ANDROID_PAYWALL_PURCHASE_COMPLETED_SUCCESS, + ANDROID_PAYWALL_PURCHASE_FAILED, + ANDROID_PAYWALL_PURCHASE_STARTED, + ANDROID_PAYWALL_RESTORE_COMPLETED_SUCCESS, + ANDROID_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED, +} from './android-paywall-bridge-event-samples'; + +// Override Platform.OS for Android tests +const originalOS = Platform.OS; +const originalSelect = Platform.select; + +beforeAll(() => { + Platform.OS = 'android'; + Platform.select = jest.fn((obj: any) => obj.android || obj.default); +}); + +afterAll(() => { + Platform.OS = originalOS; + Platform.select = originalSelect; +}); + +describe('ViewController - onAndroidSystemBack event (Android fields)', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onAndroidSystemBack handler when system back is pressed', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onAndroidSystemBack: handler }); + + const viewId = (view as any).id; + const sample = ANDROID_PAYWALL_USER_ACTION_SYSTEM_BACK; + + emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(); + }); + + it('should auto-dismiss paywall when onAndroidSystemBack event is emitted with default handler', async () => { + const viewId = (view as any).id; + const sample = ANDROID_PAYWALL_USER_ACTION_SYSTEM_BACK; + + const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(undefined); + + emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view); + + expect(dismissSpy).toHaveBeenCalledTimes(1); + + dismissSpy.mockRestore(); + }); + + it('should allow overriding default onAndroidSystemBack handler', async () => { + const viewId = (view as any).id; + const sample = ANDROID_PAYWALL_USER_ACTION_SYSTEM_BACK; + + const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(undefined); + + const customHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onAndroidSystemBack: customHandler }); + + emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view); + + expect(customHandler).toHaveBeenCalledTimes(1); + expect(dismissSpy).not.toHaveBeenCalled(); + + dismissSpy.mockRestore(); + }); +}); + +describe('ViewController - onPurchaseStarted event (Android fields)', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPurchaseStarted handler with Android-specific subscription fields', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseStarted: handler }); + + const viewId = (view as any).id; + const sample = ANDROID_PAYWALL_PURCHASE_STARTED; + + emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [product] = handler.mock.calls[0]!; + + // Verify Android-specific subscription fields + expect(product.subscription).toBeDefined(); + if (product.subscription) { + expect(product.subscription.android).toBeDefined(); + if (product.subscription.android) { + expect(product.subscription.android.renewalType).toBe('autorenewable'); + expect(product.subscription.android.basePlanId).toBe( + 'yearly-premium-6999-base', + ); + } + + // Verify iOS-specific fields are NOT present + expect(product.subscription).not.toHaveProperty('ios'); + if (product.subscription.offer) { + expect(product.subscription.offer).not.toHaveProperty('identifier'); + expect(product.subscription.offer).not.toHaveProperty('phases'); + } + } + // iOS property may exist but should be empty for Android products + if (product.ios) { + expect(Object.keys(product.ios)).toHaveLength(0); + } + }); +}); + +describe('ViewController - onPurchaseCompleted event (Android fields)', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPurchaseCompleted handler with Android-specific purchase result fields', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseCompleted: handler }); + + const viewId = (view as any).id; + const sample = ANDROID_PAYWALL_PURCHASE_COMPLETED_SUCCESS; + + emitPaywallPurchaseCompletedEvent( + viewId, + sample.purchased_result, + sample.product, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [purchaseResult, product] = handler.mock.calls[0]!; + + // Verify Android-specific purchase result fields + expect(purchaseResult.type).toBe('success'); + if (purchaseResult.type === 'success') { + expect(purchaseResult.android).toBeDefined(); + if (purchaseResult.android) { + expect(purchaseResult.android.purchaseToken).toBe( + sample.purchased_result.google_purchase_token, + ); + expect(purchaseResult.android.purchaseToken).toContain('AO-J1Oy'); + } + + // Verify iOS-specific field is NOT present + expect(purchaseResult).not.toHaveProperty('ios'); + + // Verify Android store in profile + expect(purchaseResult.profile).toBeDefined(); + if (purchaseResult.profile.subscriptions) { + const subscription = Object.values( + purchaseResult.profile.subscriptions, + )[0] as AdaptySubscription | undefined; + if (subscription) { + expect(subscription.store).toBe('play_store'); + expect(subscription.vendorTransactionId).toMatch(/^GPA\./); + expect(subscription.vendorOriginalTransactionId).toMatch(/^GPA\./); + } + } + } + + // Verify Android-specific product subscription fields + if (product.subscription?.android) { + expect(product.subscription.android.renewalType).toBe('autorenewable'); + expect(product.subscription.android.basePlanId).toBe( + 'weekly-premium-599-base', + ); + if (product.subscription.offer?.android) { + expect(product.subscription.offer.android.offerTags).toEqual([]); + } + } + }); +}); + +describe('ViewController - onPurchaseFailed event (Android fields)', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPurchaseFailed handler with Android-specific product fields', async () => { + const handler: jest.MockedFunction = jest + .fn() + .mockReturnValue(false); + + view.setEventHandlers({ onPurchaseFailed: handler }); + + const viewId = (view as any).id; + const sample = ANDROID_PAYWALL_PURCHASE_FAILED; + + emitPaywallPurchaseFailedEvent( + viewId, + sample.error, + sample.product, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [error, product] = handler.mock.calls[0]!; + + expect(error).toBeInstanceOf(AdaptyError); + expect(error.adaptyCode).toBe(sample.error.adapty_code); + + // Verify Android-specific subscription fields + expect(product.subscription).toBeDefined(); + if (product.subscription?.android) { + expect(product.subscription.android.renewalType).toBe('autorenewable'); + expect(product.subscription.android.basePlanId).toBe( + 'monthly-premium-999-base', + ); + } + + // Verify iOS-specific fields are NOT present + if (product.subscription) { + expect(product.subscription).not.toHaveProperty('ios'); + } + // iOS property may exist but should be empty for Android products + if (product.ios) { + expect(Object.keys(product.ios)).toHaveLength(0); + } + }); +}); + +describe('ViewController - onRestoreCompleted event (Android fields)', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onRestoreCompleted handler with Android-specific profile fields', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onRestoreCompleted: handler }); + + const viewId = (view as any).id; + const sample = ANDROID_PAYWALL_RESTORE_COMPLETED_SUCCESS; + + emitPaywallRestoreCompletedEvent(viewId, sample.profile, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [profile] = handler.mock.calls[0]!; + + expect(profile.profileId).toBe(sample.profile.profile_id); + + // Verify Android store in subscriptions + if (profile.subscriptions) { + const subscriptions = Object.values( + profile.subscriptions, + ) as AdaptySubscription[]; + expect(subscriptions.length).toBeGreaterThan(0); + subscriptions.forEach(subscription => { + expect(subscription.store).toBe('play_store'); + expect(subscription.vendorTransactionId).toMatch(/^GPA\./); + expect(subscription.vendorOriginalTransactionId).toMatch(/^GPA\./); + }); + } + + // Verify Android store in access levels + if (profile.accessLevels) { + const accessLevels = Object.values( + profile.accessLevels, + ) as AdaptyAccessLevel[]; + expect(accessLevels.length).toBeGreaterThan(0); + accessLevels.forEach(accessLevel => { + expect(accessLevel.store).toBe('play_store'); + }); + } + }); +}); + +describe('ViewController - onWebPaymentNavigationFinished event (Android fields)', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onWebPaymentNavigationFinished handler with Android-specific product fields', async () => { + const handler: jest.MockedFunction< + EventHandlers['onWebPaymentNavigationFinished'] + > = jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onWebPaymentNavigationFinished: handler }); + + const viewId = (view as any).id; + const sample = ANDROID_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED; + + emitPaywallWebPaymentNavigationFinishedEvent( + viewId, + sample.product, + undefined, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [product] = handler.mock.calls[0]!; + + expect(product).toBeDefined(); + if (product) { + expect(product.vendorProductId).toBe(sample.product.vendor_product_id); + + // Verify Android-specific subscription fields + if (product.subscription?.android) { + expect(product.subscription.android.renewalType).toBe('autorenewable'); + expect(product.subscription.android.basePlanId).toBe( + 'sixmonth-premium-999-base', + ); + } + + // Verify iOS-specific fields are NOT present + if (product.subscription) { + expect(product.subscription).not.toHaveProperty('ios'); + } + // iOS property may exist but should be empty for Android products + if (product.ios) { + expect(Object.keys(product.ios)).toHaveLength(0); + } + } + }); +}); diff --git a/src/__tests__/integration/ui/paywall/ios-paywall-bridge-event-samples.ts b/src/__tests__/integration/ui/paywall/ios-paywall-bridge-event-samples.ts new file mode 100644 index 00000000..65ef088a --- /dev/null +++ b/src/__tests__/integration/ui/paywall/ios-paywall-bridge-event-samples.ts @@ -0,0 +1,431 @@ +/** + * iOS-specific bridge event samples in native format (snake_case) + * + * Real iOS event data extracted from native logs for accurate testing. + * Android-specific fields have been removed, iOS-specific fields are included. + * + * Use these samples for integration tests to verify iOS-specific event handling. + */ + +/** + * Sample for PaywallViewEvent.WillPurchase (iOS) + * @see cross_platform.yaml#/$events/PaywallViewEvent.WillPurchase + */ +export const IOS_PAYWALL_PURCHASE_STARTED = { + view: { + variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + placement_id: '3968c273-f247-4b9f-bd90-305be39d6414', + id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF', + }, + product: { + access_level_id: 'premium', + vendor_product_id: 'yearly.premium.6999', + localized_title: '1 Year Premium', + localized_description: '1 Year Premium Description', + paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + price: { + currency_code: 'USD', + currency_symbol: '$', + localized_string: '$69.99', + amount: 69.99, + }, + paywall_product_index: 1, + paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + product_type: 'annual', + paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd', + region_code: 'US', + is_family_shareable: false, + subscription: { + period: { + unit: 'year', + number_of_units: 1, + }, + offer: { + phases: [ + { + number_of_periods: 1, + payment_mode: 'free_trial', + localized_number_of_periods: '1 month', + localized_subscription_period: '1 month', + subscription_period: { + unit: 'month', + number_of_units: 1, + }, + price: { + amount: 0, + currency_code: 'USD', + localized_string: '$0.00', + }, + }, + ], + offer_identifier: { + type: 'introductory', + }, + }, + group_identifier: '20770576', + localized_period: '1 year', + }, + }, + id: 'paywall_view_did_start_purchase', +} as const; + +/** + * Sample for PaywallViewEvent.DidPurchase with successful purchase result (iOS) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidPurchase + */ +export const IOS_PAYWALL_PURCHASE_COMPLETED_SUCCESS = { + view: { + id: '88df97f8-ca94-43a4-bd4a-1749a89988e8', + placement_id: 'AdaptyRnSdkExample1', + variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c', + }, + product: { + access_level_id: 'premium', + is_family_shareable: false, + localized_description: '1 Year Premium Description', + localized_title: '1 Year Premium', + payload_data: + 'eyJjdXJyZW5jeV9jb2RlIjoiVVNEIiwicHJpY2VfYW1vdW50X21pY3JvcyI6Njk5OTAwMCwic3Vic2NyaXB0aW9uX2RhdGEiOnsiZ3JvdXBfaWRlbnRpZmllciI6IjIwNzcwNTc2Iiwib2ZmZXJfaWRlbnRpZmllciI6bnVsbH0sInR5cGUiOiJzdWJzIn0=', + paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + price: { + amount: 69.99, + currency_code: 'USD', + currency_symbol: '$', + localized_string: '$69.99', + }, + product_type: 'annual', + subscription: { + period: { + unit: 'year', + number_of_units: 1, + }, + offer: { + phases: [ + { + number_of_periods: 1, + payment_mode: 'free_trial', + localized_number_of_periods: '1 month', + localized_subscription_period: '1 month', + subscription_period: { + unit: 'month', + number_of_units: 1, + }, + price: { + amount: 0, + currency_code: 'USD', + localized_string: '$0.00', + }, + }, + ], + offer_identifier: { + type: 'introductory', + }, + }, + group_identifier: '20770576', + localized_period: '1 year', + }, + paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + vendor_product_id: 'yearly.premium.6999', + paywall_product_index: 1, + adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd', + }, + purchased_result: { + type: 'success', + apple_jws_transaction: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQ1Njc4OTAifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiZXhwIjoxNzM1Mjg4MDAwLCJpYXQiOjE3MzUyODQ0MDAsImp0aSI6IjEyMzQ1Njc4OTAtYWJjZGVmZ2hpamsifQ.signature', + profile: { + paid_access_levels: { + premium: { + activated_at: '2025-12-26T13:36:09.931000+0000', + expires_at: '2025-12-26T13:41:09.549000+0000', + id: 'premium', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + renewed_at: '2025-12-26T13:36:09.931000+0000', + store: 'app_store', + vendor_product_id: 'yearly.premium.6999', + will_renew: true, + }, + }, + custom_attributes: {}, + is_test_user: false, + non_subscriptions: {}, + profile_id: 'cbdabead-697c-4804-9ea5-7ccaa83411c7', + subscriptions: { + 'yearly.premium.6999': { + activated_at: '2025-12-26T13:36:09.931000+0000', + expires_at: '2025-12-26T13:41:09.549000+0000', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + is_sandbox: true, + renewed_at: '2025-12-26T13:36:09.931000+0000', + store: 'app_store', + vendor_original_transaction_id: '1000000123456789', + vendor_product_id: 'yearly.premium.6999', + vendor_transaction_id: '1000000123456789', + will_renew: true, + }, + }, + segment_hash: 'not implemented', + timestamp: -1, + }, + }, + id: 'paywall_view_did_finish_purchase', +} as const; + +/** + * Sample for PaywallViewEvent.DidPurchase with user_cancelled result (iOS) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidPurchase + */ +export const IOS_PAYWALL_PURCHASE_COMPLETED_CANCELLED = { + view: { + variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + placement_id: '3968c273-f247-4b9f-bd90-305be39d6414', + id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF', + }, + purchased_result: { + type: 'user_cancelled', + }, + product: { + access_level_id: 'premium', + vendor_product_id: 'yearly.premium.6999', + is_family_shareable: false, + product_type: 'annual', + localized_description: '1 Year Premium Description', + paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + paywall_product_index: 1, + localized_title: '1 Year Premium', + subscription: { + period: { + unit: 'year', + number_of_units: 1, + }, + offer: { + phases: [ + { + number_of_periods: 1, + payment_mode: 'free_trial', + localized_number_of_periods: '1 month', + localized_subscription_period: '1 month', + subscription_period: { + unit: 'month', + number_of_units: 1, + }, + price: { + amount: 0, + currency_code: 'USD', + localized_string: '$0.00', + }, + }, + ], + offer_identifier: { + type: 'introductory', + }, + }, + group_identifier: '20770576', + localized_period: '1 year', + }, + region_code: 'US', + paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + price: { + currency_symbol: '$', + currency_code: 'USD', + amount: 69.99, + localized_string: '$69.99', + }, + adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd', + paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + }, + id: 'paywall_view_did_finish_purchase', +} as const; + +/** + * Sample for PaywallViewEvent.DidFailPurchase (iOS) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailPurchase + */ +export const IOS_PAYWALL_PURCHASE_FAILED = { + view: { + id: '2064bb24-39e4-4c06-a9aa-4417357edfb4', + placement_id: 'test_placement', + variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + }, + product: { + access_level_id: 'premium', + localized_description: '1 Year Premium Description', + localized_title: '1 Year Premium', + payload_data: + 'eyJjdXJyZW5jeV9jb2RlIjoiVVNEIiwicHJpY2VfYW1vdW50X21pY3JvcyI6Njk5OTAwMCwic3Vic2NyaXB0aW9uX2RhdGEiOnsiZ3JvdXBfaWRlbnRpZmllciI6IjIwNzcwNTc2Iiwib2ZmZXJfaWRlbnRpZmllciI6bnVsbH0sInR5cGUiOiJzdWJzIn0=', + paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + price: { + amount: 69.99, + currency_code: 'USD', + currency_symbol: '$', + localized_string: '$69.99', + }, + product_type: 'annual', + subscription: { + period: { + unit: 'year', + number_of_units: 1, + }, + offer: { + phases: [ + { + number_of_periods: 1, + payment_mode: 'free_trial', + localized_number_of_periods: '1 month', + localized_subscription_period: '1 month', + subscription_period: { + unit: 'month', + number_of_units: 1, + }, + price: { + amount: 0, + currency_code: 'USD', + localized_string: '$0.00', + }, + }, + ], + offer_identifier: { + type: 'introductory', + }, + }, + group_identifier: '20770576', + localized_period: '1 year', + }, + paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + vendor_product_id: 'yearly.premium.6999', + paywall_product_index: 1, + adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd', + region_code: 'US', + is_family_shareable: false, + }, + error: { + adapty_code: 103, + message: 'StoreKit purchase failed: User cancelled', + }, + id: 'paywall_view_did_fail_purchase', +} as const; + +/** + * Sample for PaywallViewEvent.DidRestorePurchase with successful restore (iOS) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidRestorePurchase + */ +export const IOS_PAYWALL_RESTORE_COMPLETED_SUCCESS = { + view: { + id: '3980be37-7a25-4c38-aace-68ee46b2927c', + placement_id: 'test_placement', + variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + }, + profile: { + paid_access_levels: { + premium: { + activated_at: '2025-12-26T14:15:01.977000+0000', + active_introductory_offer_type: 'free_trial', + expires_at: '2025-12-26T14:18:01.719000+0000', + id: 'premium', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + offer_id: 'intro-free-trial-yearly', + renewed_at: '2025-12-26T14:15:01.977000+0000', + store: 'app_store', + vendor_product_id: 'yearly.premium.6999', + will_renew: true, + }, + }, + custom_attributes: {}, + is_test_user: false, + non_subscriptions: {}, + profile_id: 'd200c008-13fd-4557-9c51-cff73b45a7f2', + subscriptions: { + 'yearly.premium.6999': { + activated_at: '2025-12-26T14:15:01.977000+0000', + active_introductory_offer_type: 'free_trial', + expires_at: '2025-12-26T14:18:01.719000+0000', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + is_sandbox: true, + offer_id: 'intro-free-trial-yearly', + renewed_at: '2025-12-26T14:15:01.977000+0000', + store: 'app_store', + vendor_original_transaction_id: '1000000123456789', + vendor_product_id: 'yearly.premium.6999', + vendor_transaction_id: '1000000123456789', + will_renew: true, + }, + 'monthly.premium.999': { + activated_at: '2025-12-26T13:36:09.931000+0000', + cancellation_reason: 'unknown', + expires_at: '2025-12-26T13:41:09.549000+0000', + is_active: false, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + is_sandbox: true, + renewed_at: '2025-12-26T13:36:09.931000+0000', + store: 'app_store', + vendor_original_transaction_id: '1000000987654321', + vendor_product_id: 'monthly.premium.999', + vendor_transaction_id: '1000000987654321', + will_renew: false, + }, + }, + segment_hash: 'not implemented', + timestamp: -1, + }, + id: 'paywall_view_did_finish_restore', +} as const; + +/** + * Sample for PaywallViewEvent.DidFinishWebPaymentNavigation (iOS) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFinishWebPaymentNavigation + */ +export const IOS_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED = { + view: { + id: '2442A0E9-FB7F-4369-87BB-61C80222AFA1', + variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48', + placement_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48', + }, + product: { + region_code: 'US', + product_type: 'semiannual', + paywall_name: 'rt.web', + vendor_product_id: 'sixmonth.premium.999', + localized_title: 'Six Months Premium', + web_purchase_url: 'https://example.com', + access_level_id: 'premium', + paywall_ab_test_name: 'rt.web', + subscription: { + period: { + unit: 'month', + number_of_units: 6, + }, + group_identifier: '20770576', + localized_period: '6 months', + offer: null, + }, + localized_description: 'Six Months Premium Description', + adapty_product_id: '0f2e86da-4d6f-435e-bc8f-9f2d1c265a27', + paywall_variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48', + price: { + currency_symbol: '$', + localized_string: '$14.99', + currency_code: 'USD', + amount: 14.99, + }, + is_family_shareable: false, + paywall_product_index: 0, + }, + id: 'paywall_view_did_finish_web_payment_navigation', +} as const; diff --git a/src/__tests__/integration/ui/paywall/ios-paywall-view-controller-events.test.ts b/src/__tests__/integration/ui/paywall/ios-paywall-view-controller-events.test.ts new file mode 100644 index 00000000..5ae18e42 --- /dev/null +++ b/src/__tests__/integration/ui/paywall/ios-paywall-view-controller-events.test.ts @@ -0,0 +1,642 @@ +import { Platform } from 'react-native'; +import { Adapty } from '@/adapty-handler'; +import { AdaptyError } from '@/adapty-error'; +import { ViewController } from '@/ui/view-controller'; +import { EventHandlers } from '@/ui/types'; +import type { AdaptySubscription, AdaptyAccessLevel } from '@/types'; +import { + createPaywallViewController, + cleanupPaywallViewController, +} from '../setup.utils'; +import { + emitPaywallPurchaseStartedEvent, + emitPaywallPurchaseCompletedEvent, + emitPaywallPurchaseFailedEvent, + emitPaywallRestoreCompletedEvent, + emitPaywallWebPaymentNavigationFinishedEvent, +} from './paywall-event-emitter.utils'; +import { + IOS_PAYWALL_PURCHASE_STARTED, + IOS_PAYWALL_PURCHASE_COMPLETED_SUCCESS, + IOS_PAYWALL_PURCHASE_COMPLETED_CANCELLED, + IOS_PAYWALL_PURCHASE_FAILED, + IOS_PAYWALL_RESTORE_COMPLETED_SUCCESS, + IOS_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED, +} from './ios-paywall-bridge-event-samples'; + +// Override Platform.OS for iOS tests +const originalOS = Platform.OS; +const originalSelect = Platform.select; + +beforeAll(() => { + Platform.OS = 'ios'; + Platform.select = jest.fn((obj: any) => obj.ios || obj.default); +}); + +afterAll(() => { + Platform.OS = originalOS; + Platform.select = originalSelect; +}); + +describe('ViewController - onPurchaseStarted event (iOS fields)', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPurchaseStarted handler with iOS-specific subscription fields', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseStarted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_PURCHASE_STARTED; + + emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [product] = handler.mock.calls[0]!; + + // Verify iOS-specific subscription fields + expect(product.subscription).toBeDefined(); + if (product.subscription) { + expect(product.subscription.ios).toBeDefined(); + if (product.subscription.ios) { + expect(product.subscription.ios.subscriptionGroupIdentifier).toBe( + '20770576', + ); + expect( + typeof product.subscription.ios.subscriptionGroupIdentifier, + ).toBe('string'); + } + + // Verify Android-specific fields are NOT present + expect(product.subscription).not.toHaveProperty('android'); + } + + // Verify iOS-specific product fields + expect(product.ios).toBeDefined(); + if (product.ios) { + expect(product.ios.isFamilyShareable).toBe(false); + expect(typeof product.ios.isFamilyShareable).toBe('boolean'); + } + }); + + it('should contain is_family_shareable field in product (iOS)', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseStarted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_PURCHASE_STARTED; + + emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [product] = handler.mock.calls[0]!; + + // iOS-specific fields + expect(product.ios).toBeDefined(); + if (product.ios) { + expect(product.ios.isFamilyShareable).toBe(false); // camelCase in JS + expect(typeof product.ios.isFamilyShareable).toBe('boolean'); + } + + expect(product.subscription?.ios?.subscriptionGroupIdentifier).toBe( + '20770576', + ); // camelCase + expect(typeof product.subscription?.ios?.subscriptionGroupIdentifier).toBe( + 'string', + ); + + // Android-specific fields should NOT be present + expect(product.subscription).not.toHaveProperty('android'); + if (product.ios) { + expect(Object.keys(product.ios)).toContain('isFamilyShareable'); + } + }); + + it('should contain group_identifier in subscription (iOS)', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseStarted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_PURCHASE_STARTED; + + emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [product] = handler.mock.calls[0]!; + + // Verify group_identifier format + expect( + product.subscription?.ios?.subscriptionGroupIdentifier, + ).toBeDefined(); + expect(typeof product.subscription?.ios?.subscriptionGroupIdentifier).toBe( + 'string', + ); + expect(product.subscription?.ios?.subscriptionGroupIdentifier).toBe( + '20770576', + ); + }); +}); + +describe('ViewController - onPurchaseCompleted event (iOS fields)', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPurchaseCompleted handler with iOS-specific purchase result fields', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseCompleted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_PURCHASE_COMPLETED_SUCCESS; + + emitPaywallPurchaseCompletedEvent( + viewId, + sample.purchased_result, + sample.product, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [purchaseResult, product] = handler.mock.calls[0]!; + + // Verify iOS-specific purchase result fields + expect(purchaseResult.type).toBe('success'); + if (purchaseResult.type === 'success') { + expect(purchaseResult.ios).toBeDefined(); + if (purchaseResult.ios?.jwsTransaction) { + expect(typeof purchaseResult.ios.jwsTransaction).toBe('string'); + expect(purchaseResult.ios.jwsTransaction.startsWith('eyJ')).toBe(true); // JWT format + } + + // Verify Android-specific field is NOT present + expect(purchaseResult).not.toHaveProperty('android'); + + // Verify iOS store in profile + expect(purchaseResult.profile).toBeDefined(); + if (purchaseResult.profile.subscriptions) { + const subscription = Object.values( + purchaseResult.profile.subscriptions, + )[0] as AdaptySubscription | undefined; + if (subscription) { + expect(subscription.store).toBe('app_store'); + } + } + } + + // Verify iOS-specific product subscription fields + if (product.subscription?.ios) { + expect(product.subscription.ios.subscriptionGroupIdentifier).toBe( + '20770576', + ); + } + if (product.ios) { + expect(product.ios.isFamilyShareable).toBe(false); + } + }); + + it('should contain apple_jws_transaction in successful purchase result (iOS)', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseCompleted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_PURCHASE_COMPLETED_SUCCESS; + + emitPaywallPurchaseCompletedEvent( + viewId, + sample.purchased_result, + sample.product, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [purchaseResult] = handler.mock.calls[0]!; + + // Check purchase result type + expect(purchaseResult.type).toBe('success'); + + // iOS-specific field in success variant + if (purchaseResult.type === 'success') { + expect(purchaseResult.ios).toBeDefined(); + if (purchaseResult.ios?.jwsTransaction) { + expect(typeof purchaseResult.ios.jwsTransaction).toBe('string'); + expect(purchaseResult.ios.jwsTransaction.startsWith('eyJ')).toBe(true); // JWT format + } + + // Android-specific field should NOT be present + expect(purchaseResult).not.toHaveProperty('android'); + } + }); + + it('should NOT contain apple_jws_transaction in cancelled purchase result (iOS)', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseCompleted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_PURCHASE_COMPLETED_CANCELLED; + + emitPaywallPurchaseCompletedEvent( + viewId, + sample.purchased_result, + sample.product, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [purchasedResult] = handler.mock.calls[0]!; + + // Check purchase result type + expect(purchasedResult.type).toBe('user_cancelled'); + + // No transaction fields in cancelled result + if (purchasedResult.type === 'user_cancelled') { + expect(purchasedResult).not.toHaveProperty('ios'); + expect(purchasedResult).not.toHaveProperty('profile'); + } + }); + + it('should contain iOS product fields in cancelled purchase result', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseCompleted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_PURCHASE_COMPLETED_CANCELLED; + + emitPaywallPurchaseCompletedEvent( + viewId, + sample.purchased_result, + sample.product, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [, product] = handler.mock.calls[0]!; + + // Verify iOS-specific product fields + if (product?.ios) { + expect(product.ios.isFamilyShareable).toBe(false); + } + if (product?.subscription?.ios) { + expect(product.subscription.ios.subscriptionGroupIdentifier).toBe( + '20770576', + ); + } + }); +}); + +describe('ViewController - onPurchaseFailed event (iOS fields)', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPurchaseFailed handler with iOS-specific product fields', async () => { + const handler: jest.MockedFunction = jest + .fn() + .mockReturnValue(false); + + view.setEventHandlers({ onPurchaseFailed: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_PURCHASE_FAILED; + + emitPaywallPurchaseFailedEvent( + viewId, + sample.error, + sample.product, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [error, product] = handler.mock.calls[0]!; + + expect(error).toBeInstanceOf(AdaptyError); + expect(error.adaptyCode).toBe(sample.error.adapty_code); + + // Verify iOS-specific subscription fields + expect(product.subscription).toBeDefined(); + if (product.subscription?.ios) { + expect(product.subscription.ios.subscriptionGroupIdentifier).toBe( + '20770576', + ); + } + + // Verify iOS-specific product fields + if (product.ios) { + expect(product.ios.isFamilyShareable).toBe(false); + } + + // Verify Android-specific fields are NOT present + if (product.subscription) { + expect(product.subscription).not.toHaveProperty('android'); + } + }); +}); + +describe('ViewController - onRestoreCompleted event (iOS fields)', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onRestoreCompleted handler with iOS-specific profile fields', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onRestoreCompleted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_RESTORE_COMPLETED_SUCCESS; + + emitPaywallRestoreCompletedEvent(viewId, sample.profile, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [profile] = handler.mock.calls[0]!; + + expect(profile.profileId).toBe(sample.profile.profile_id); + + // Verify iOS store in subscriptions + if (profile.subscriptions) { + const subscriptions = Object.values( + profile.subscriptions, + ) as AdaptySubscription[]; + expect(subscriptions.length).toBeGreaterThan(0); + subscriptions.forEach(subscription => { + expect(subscription.store).toBe('app_store'); + // iOS transaction IDs are numeric strings + if (subscription.vendorTransactionId) { + expect(subscription.vendorTransactionId).toMatch(/^\d+$/); + } + if (subscription.vendorOriginalTransactionId) { + expect(subscription.vendorOriginalTransactionId).toMatch(/^\d+$/); + } + }); + } + + // Verify iOS store in access levels + if (profile.accessLevels) { + const accessLevels = Object.values( + profile.accessLevels, + ) as AdaptyAccessLevel[]; + expect(accessLevels.length).toBeGreaterThan(0); + accessLevels.forEach(accessLevel => { + expect(accessLevel.store).toBe('app_store'); + }); + } + }); + + it('should contain store="app_store" in profile subscriptions and access levels (iOS)', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onRestoreCompleted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_RESTORE_COMPLETED_SUCCESS; + + emitPaywallRestoreCompletedEvent(viewId, sample.profile, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + + const receivedProfile = handler.mock.calls[0]![0]; + + // Check subscriptions store field + const subscriptionKeys = Object.keys(receivedProfile.subscriptions || {}); + expect(subscriptionKeys.length).toBeGreaterThan(0); + + subscriptionKeys.forEach(key => { + const subscription = receivedProfile.subscriptions![key]; + if (subscription) { + expect(subscription.store).toBe('app_store'); // iOS store + } + }); + + // Check paid access levels store field + const accessLevelKeys = Object.keys(receivedProfile.accessLevels || {}); + expect(accessLevelKeys.length).toBeGreaterThan(0); + + accessLevelKeys.forEach(key => { + const accessLevel = receivedProfile.accessLevels![key]; + if (accessLevel) { + expect(accessLevel.store).toBe('app_store'); // iOS store + } + }); + }); + + it('should have iOS transaction IDs format in profile', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onRestoreCompleted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_RESTORE_COMPLETED_SUCCESS; + + emitPaywallRestoreCompletedEvent(viewId, sample.profile, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [profile] = handler.mock.calls[0]!; + + // Verify iOS transaction IDs format + if (profile.subscriptions) { + const subscriptions = Object.values( + profile.subscriptions, + ) as AdaptySubscription[]; + subscriptions.forEach(subscription => { + // iOS transaction IDs are numeric strings (not GPA.xxx format) + expect(subscription.vendorTransactionId).toMatch(/^\d+$/); + expect(subscription.vendorOriginalTransactionId).toMatch(/^\d+$/); + }); + } + }); +}); + +describe('ViewController - onWebPaymentNavigationFinished event (iOS fields)', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onWebPaymentNavigationFinished handler with iOS-specific product fields', async () => { + const handler: jest.MockedFunction< + EventHandlers['onWebPaymentNavigationFinished'] + > = jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onWebPaymentNavigationFinished: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED; + + emitPaywallWebPaymentNavigationFinishedEvent( + viewId, + sample.product, + undefined, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [product] = handler.mock.calls[0]!; + + expect(product).toBeDefined(); + if (product) { + expect(product.vendorProductId).toBe(sample.product.vendor_product_id); + + // Verify iOS-specific subscription fields + if (product.subscription?.ios) { + expect(product.subscription.ios.subscriptionGroupIdentifier).toBe( + '20770576', + ); + } + + // Verify iOS-specific product fields + if (product.ios) { + expect(product.ios.isFamilyShareable).toBe(false); + } + + // Verify Android-specific fields are NOT present + if (product.subscription) { + expect(product.subscription).not.toHaveProperty('android'); + } + } + }); +}); + +describe('ViewController - iOS fields absence in platform-independent events', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should NOT contain iOS-specific fields in events without products', async () => { + // This test verifies that events without products don't contain iOS-specific fields + // For example, onPaywallShown, onPaywallClosed, etc. + // These events only contain view information, no product data + expect(true).toBe(true); // Placeholder - actual implementation would test view-only events + }); +}); + +describe('ViewController - Android fields absence in iOS events', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should NOT contain Android-specific fields in iOS purchase started event', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseStarted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_PURCHASE_STARTED; + + emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [product] = handler.mock.calls[0]!; + + // Verify Android-specific fields are NOT present + if (product.subscription) { + expect(product.subscription).not.toHaveProperty('android'); + expect(product.subscription.android).toBeUndefined(); + } + }); + + it('should NOT contain Android-specific fields in iOS purchase completed event', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseCompleted: handler }); + + const viewId = (view as any).id; + const sample = IOS_PAYWALL_PURCHASE_COMPLETED_SUCCESS; + + emitPaywallPurchaseCompletedEvent( + viewId, + sample.purchased_result, + sample.product, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [purchaseResult] = handler.mock.calls[0]!; + + // Verify Android-specific fields are NOT present + if (purchaseResult.type === 'success') { + expect(purchaseResult).not.toHaveProperty('android'); + expect(purchaseResult.android).toBeUndefined(); + } + }); +}); diff --git a/src/__tests__/integration/ui/paywall/paywall-bridge-event-samples.ts b/src/__tests__/integration/ui/paywall/paywall-bridge-event-samples.ts new file mode 100644 index 00000000..e45ec159 --- /dev/null +++ b/src/__tests__/integration/ui/paywall/paywall-bridge-event-samples.ts @@ -0,0 +1,639 @@ +/** + * Bridge event samples in native format (snake_case) + * + * Real event data extracted from native logs for accurate testing. + * + * NOTE: These samples may contain platform-specific fields (iOS: is_family_shareable, group_identifier; + * Android: base_plan_id, renewal_type, google_purchase_token). When used in base tests + * (paywall-view-controller-events.test.ts), only platform-independent fields are verified. + * Platform-specific fields are tested in: + * - ios-paywall-view-controller-events.test.ts + * - android-paywall-view-controller-events.test.ts + * + * Use these samples for integration tests to verify event handling. + */ + +/** + * Sample for PaywallViewEvent.DidSelectProduct + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidSelectProduct + */ +export const PAYWALL_PRODUCT_SELECTED_YEARLY = { + id: 'paywall_view_did_select_product', + product_id: 'yearly.premium.6999', + view: { + placement_id: '3968c273-f247-4b9f-bd90-305be39d6414', + id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF', + variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + }, +} as const; + +/** + * Sample for PaywallViewEvent.DidSelectProduct (six month product) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidSelectProduct + */ +export const PAYWALL_PRODUCT_SELECTED_SIXMONTH = { + view: { + id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF', + placement_id: '3968c273-f247-4b9f-bd90-305be39d6414', + variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + }, + id: 'paywall_view_did_select_product', + product_id: 'sixmonth.premium.999', +} as const; + +/** + * Sample for PaywallViewEvent.DidDisappear + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidDisappear + */ +export const PAYWALL_VIEW_DISAPPEARED = { + view: { + variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + placement_id: '3968c273-f247-4b9f-bd90-305be39d6414', + id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF', + }, + id: 'paywall_view_did_disappear', +} as const; + +/** + * Sample for PaywallViewEvent.DidUserAction with close action + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidUserAction + */ +export const PAYWALL_USER_ACTION_CLOSE = { + view: { + variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF', + placement_id: '3968c273-f247-4b9f-bd90-305be39d6414', + }, + action: { + type: 'close', + }, + id: 'paywall_view_did_perform_action', +} as const; + +/** + * Sample for PaywallViewEvent.DidUserAction with open_url action + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidUserAction + */ +export const PAYWALL_USER_ACTION_OPEN_URL = { + id: 'paywall_view_did_perform_action', + action: { + value: 'https://example.com/terms', + type: 'open_url', + }, + view: { + placement_id: '3968c273-f247-4b9f-bd90-305be39d6414', + id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF', + variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + }, +} as const; + +/** + * Sample for PaywallViewEvent.DidUserAction with system_back action (Android) + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidUserAction + */ +export const PAYWALL_USER_ACTION_SYSTEM_BACK = { + view: { + id: '99cc6779-cf80-4fca-ae3c-9d7a7bca0fed', + placement_id: 'AdaptyRnSdkExample1', + variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c', + }, + action: { + type: 'system_back', + }, + id: 'paywall_view_did_perform_action', +} as const; + +/** + * Sample for PaywallViewEvent.DidUserAction with close action + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidUserAction + */ +export const PAYWALL_USER_ACTION_CLOSE_BUTTON = { + view: { + id: 'dc654d38-2970-4f56-a9e8-5d00bd94e7ad', + placement_id: 'AdaptyRnSdkExample1', + variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c', + }, + action: { + type: 'close', + }, + id: 'paywall_view_did_perform_action', +} as const; + +/** + * Sample for PaywallViewEvent.DidPurchase with user_cancelled result + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidPurchase + */ +export const PAYWALL_PURCHASE_COMPLETED_CANCELLED = { + view: { + variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + placement_id: '3968c273-f247-4b9f-bd90-305be39d6414', + id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF', + }, + purchased_result: { + type: 'user_cancelled', + }, + product: { + access_level_id: 'premium', + vendor_product_id: 'yearly.premium.6999', + is_family_shareable: false, + product_type: 'annual', + localized_description: '1 Year Premium Description', + paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + paywall_product_index: 1, + localized_title: '1 Year Premium', + subscription: { + period: { + unit: 'year', + number_of_units: 1, + }, + offer: { + phases: [ + { + number_of_periods: 1, + payment_mode: 'free_trial', + localized_number_of_periods: '1 month', + localized_subscription_period: '1 month', + subscription_period: { + unit: 'month', + number_of_units: 1, + }, + price: { + amount: 0, + currency_code: 'USD', + localized_string: '$0.00', + }, + }, + ], + offer_identifier: { + type: 'introductory', + }, + }, + group_identifier: '20770576', + localized_period: '1 year', + }, + region_code: 'US', + paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + price: { + currency_symbol: '$', + currency_code: 'USD', + amount: 69.99, + localized_string: '$69.99', + }, + adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd', + paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + }, + id: 'paywall_view_did_finish_purchase', +} as const; + +/** + * Sample for PaywallViewEvent.DidPurchase with successful purchase result + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidPurchase + */ +export const PAYWALL_PURCHASE_COMPLETED_SUCCESS = { + view: { + id: '88df97f8-ca94-43a4-bd4a-1749a89988e8', + placement_id: 'AdaptyRnSdkExample1', + variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c', + }, + product: { + access_level_id: 'premium', + is_family_shareable: false, + localized_description: 'sdfg', + localized_title: 'sdfg', + payload_data: + 'eyJjdXJyZW5jeV9jb2RlIjoiRVVSIiwicHJpY2VfYW1vdW50X21pY3JvcyI6MTE5OTAwMDAsInN1\nYnNjcmlwdGlvbl9kYXRhIjp7ImJhc2VfcGxhbl9pZCI6IndlZWtseS1wcmVtaXVtLTU5OS1iYXNl\nIiwib2ZmZXJfaWQiOm51bGx9LCJ0eXBlIjoic3VicyJ9\n', + paywall_ab_test_name: 'AdaptyRnSdkExample1', + paywall_name: 'AdaptyRnSdkExample1', + price: { + amount: 11.99, + currency_code: 'EUR', + currency_symbol: '€', + localized_string: '€11.99', + }, + product_type: 'weekly', + subscription: { + base_plan_id: 'weekly-premium-599-base', + localized_period: '1 week', + offer_id: 'intro-pay-up-front-weekly-premium', + offer_tags: [], + renewal_type: 'autorenewable', + period: { + number_of_units: 1, + unit: 'week', + }, + }, + paywall_variation_id: 'a24a2d05-93fe-4bcc-a76e-eef7690a436c', + vendor_product_id: 'weekly.premium.599', + web_purchase_url: + 'http://paywalls-14c3d623-2f3a-455a-aa86-ef83dff6913b.fnlfx.com/trest2', + paywall_product_index: 0, + adapty_product_id: 'b136422f-8153-402a-afbb-986929c68f6a', + }, + purchased_result: { + type: 'success', + google_purchase_token: + 'abcdefghijklmnopqrs.AO-J1Oy0BKdJa02G4rK5G7jxhSbdajFmsy3FSHTqLUwE1rApPKMSJaY_gZg6aIVgxPO10_GRD94ZCSKvoAp3EkqDLRLQ45W7-Q', + profile: { + paid_access_levels: { + premium: { + activated_at: '2025-12-26T13:36:09.931000+0000', + expires_at: '2025-12-26T13:41:09.549000+0000', + id: 'premium', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + renewed_at: '2025-12-26T13:36:09.931000+0000', + store: 'play_store', + vendor_product_id: 'weekly.premium.599', + will_renew: true, + }, + }, + custom_attributes: {}, + is_test_user: false, + non_subscriptions: {}, + profile_id: 'cbdabead-697c-4804-9ea5-7ccaa83411c7', + subscriptions: { + 'weekly.premium.599': { + activated_at: '2025-12-26T13:36:09.931000+0000', + expires_at: '2025-12-26T13:41:09.549000+0000', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + is_sandbox: true, + renewed_at: '2025-12-26T13:36:09.931000+0000', + store: 'play_store', + vendor_original_transaction_id: 'GPA.3338-3241-1006-23335', + vendor_product_id: 'weekly.premium.599', + vendor_transaction_id: 'GPA.3338-3241-1006-23335', + will_renew: true, + }, + }, + segment_hash: 'not implemented', + timestamp: -1, + }, + }, + id: 'paywall_view_did_finish_purchase', +} as const; + +/** + * Sample for PaywallViewEvent.DidFailPurchase + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailPurchase + */ +export const PAYWALL_PURCHASE_FAILED = { + view: { + id: '2064bb24-39e4-4c06-a9aa-4417357edfb4', + placement_id: 'test_placement', + variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + }, + product: { + access_level_id: 'premium', + is_family_shareable: false, + localized_description: 'Description', + localized_title: 'Title', + payload_data: + 'eyJjdXJyZW5jeV9jb2RlIjoiRVVSIiwicHJpY2VfYW1vdW50X21pY3JvcyI6MTM5OTAwMDAsInN1\nYmNjcmlwdGlvbl9kYXRhIjp7ImJhc2VfcGxhbl9pZCI6Im1vbnRobHktcHJlbWl1bS05OTktYmFz\nZSIsIm9mZmVyX2lkIjpudWxsfSwidHlwZSI6InN1YnMifQ==\n', + paywall_ab_test_name: 'test_restore_button', + paywall_name: 'test_restore_button', + price: { + amount: 13.99, + currency_code: 'EUR', + currency_symbol: '€', + localized_string: '€13.99', + }, + product_type: 'monthly', + subscription: { + base_plan_id: 'monthly-premium-999-base', + localized_period: '1 month', + offer_tags: [], + renewal_type: 'autorenewable', + period: { + number_of_units: 1, + unit: 'month', + }, + }, + paywall_variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + vendor_product_id: 'monthly.premium.999', + paywall_product_index: 0, + adapty_product_id: 'ac281b85-9294-4109-b9f1-4ab66b52d263', + }, + error: { + adapty_code: 103, + message: 'Play Market request failed on purchases updated: responseCode=3', + }, + id: 'paywall_view_did_fail_purchase', +} as const; + +/** + * Sample for PaywallViewEvent.WillPurchase + * @see cross_platform.yaml#/$events/PaywallViewEvent.WillPurchase + */ +export const PAYWALL_PURCHASE_STARTED = { + product: { + subscription: { + offer: { + phases: [ + { + localized_number_of_periods: '1 month', + price: { + amount: 0, + currency_code: 'USD', + localized_string: '$0.00', + }, + localized_subscription_period: '1 month', + number_of_periods: 1, + payment_mode: 'free_trial', + subscription_period: { + unit: 'month', + number_of_units: 1, + }, + }, + ], + offer_identifier: { + type: 'introductory', + }, + }, + group_identifier: '20770576', + localized_period: '1 year', + period: { + unit: 'year', + number_of_units: 1, + }, + }, + region_code: 'US', + access_level_id: 'premium', + is_family_shareable: false, + localized_title: '1 Year Premium', + vendor_product_id: 'yearly.premium.6999', + localized_description: '1 Year Premium Description', + paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + price: { + currency_code: 'USD', + currency_symbol: '$', + localized_string: '$69.99', + amount: 69.99, + }, + paywall_product_index: 1, + paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + product_type: 'annual', + paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd', + }, + view: { + variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + placement_id: '3968c273-f247-4b9f-bd90-305be39d6414', + id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF', + }, + id: 'paywall_view_did_start_purchase', +} as const; + +/** + * Sample for PaywallViewEvent.DidAppear + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidAppear + */ +export const PAYWALL_VIEW_APPEARED = { + id: 'paywall_view_did_appear', + view: { + id: '9EC086AC-BE4F-4FB2-AABE-8AD31AF03BDF', + placement_id: '3968c273-f247-4b9f-bd90-305be39d6414', + variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + }, +} as const; + +/** + * Sample for PaywallViewEvent.WillRestorePurchase + * @see cross_platform.yaml#/$events/PaywallViewEvent.WillRestorePurchase + */ +export const PAYWALL_RESTORE_STARTED = { + view: { + id: '3980be37-7a25-4c38-aace-68ee46b2927c', + placement_id: 'test_placement', + variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + }, + id: 'paywall_view_did_start_restore', +} as const; + +/** + * Sample for PaywallViewEvent.DidRestorePurchase with successful restore + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidRestorePurchase + */ +export const PAYWALL_RESTORE_COMPLETED_SUCCESS = { + view: { + id: '3980be37-7a25-4c38-aace-68ee46b2927c', + placement_id: 'test_placement', + variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + }, + profile: { + paid_access_levels: { + premium: { + activated_at: '2025-12-26T14:15:01.977000+0000', + active_introductory_offer_type: 'free_trial', + expires_at: '2025-12-26T14:18:01.719000+0000', + id: 'premium', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + offer_id: 'intro-free-trial-yearly', + renewed_at: '2025-12-26T14:15:01.977000+0000', + store: 'play_store', + vendor_product_id: 'yearly.premium.6999', + will_renew: true, + }, + }, + custom_attributes: {}, + is_test_user: false, + non_subscriptions: {}, + profile_id: 'd200c008-13fd-4557-9c51-cff73b45a7f2', + subscriptions: { + 'yearly.premium.6999': { + activated_at: '2025-12-26T14:15:01.977000+0000', + active_introductory_offer_type: 'free_trial', + expires_at: '2025-12-26T14:18:01.719000+0000', + is_active: true, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + is_sandbox: true, + offer_id: 'intro-free-trial-yearly', + renewed_at: '2025-12-26T14:15:01.977000+0000', + store: 'play_store', + vendor_original_transaction_id: 'GPA.3372-6866-7337-08302', + vendor_product_id: 'yearly.premium.6999', + vendor_transaction_id: 'GPA.3372-6866-7337-08302', + will_renew: true, + }, + 'weekly.premium.599': { + activated_at: '2025-12-26T13:36:09.931000+0000', + cancellation_reason: 'unknown', + expires_at: '2025-12-26T13:41:09.549000+0000', + is_active: false, + is_in_grace_period: false, + is_lifetime: false, + is_refund: false, + is_sandbox: true, + renewed_at: '2025-12-26T13:36:09.931000+0000', + store: 'play_store', + vendor_original_transaction_id: 'GPA.3338-3241-1006-23335', + vendor_product_id: 'weekly.premium.599', + vendor_transaction_id: 'GPA.3338-3241-1006-23335', + will_renew: false, + }, + }, + segment_hash: 'not implemented', + timestamp: -1, + }, + id: 'paywall_view_did_finish_restore', +} as const; + +/** + * Sample for PaywallViewEvent.DidFailRestorePurchase + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailRestorePurchase + */ +export const PAYWALL_RESTORE_FAILED = { + view: { + id: '0316c394-5e33-49bf-8fdd-49f07d74c065', + placement_id: 'test_placement', + variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + }, + error: { + adapty_code: 103, + message: + 'Play Market request failed: Billing service unavailable on device.', + }, + id: 'paywall_view_did_fail_restore', +} as const; + +/** + * Sample for PaywallViewEvent.DidFailLoadingProducts + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailLoadingProducts + */ +export const PAYWALL_LOADING_PRODUCTS_FAILED = { + view: { + id: '0316c394-5e33-49bf-8fdd-49f07d74c065', + placement_id: 'test_placement', + variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + }, + error: { + adapty_code: 103, + message: + 'Play Market request failed: Billing service unavailable on device.', + }, + id: 'paywall_view_did_fail_loading_products', +} as const; + +/** + * Sample for PaywallViewEvent.DidFailRendering + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFailRendering + */ +export const PAYWALL_RENDERING_FAILED = { + view: { + id: '2064bb24-39e4-4c06-a9aa-4417357edfb4', + placement_id: 'test_placement', + variation_id: '61d30b4d-d92e-4494-8d78-f3b0f4356fae', + }, + error: { + adapty_code: 23, + message: + 'The paywall JSON is not valid. Fix it in the Adapty Dashboard. Refer to the Customize paywall with remote config topic for details on how to fix it.', + }, + id: 'paywall_view_did_fail_rendering', +} as const; + +/** + * Sample for PaywallViewEvent.DidFinishWebPaymentNavigation + * @see cross_platform.yaml#/$events/PaywallViewEvent.DidFinishWebPaymentNavigation + */ +export const PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED = { + view: { + id: '2442A0E9-FB7F-4369-87BB-61C80222AFA1', + variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48', + placement_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48', + }, + product: { + region_code: 'US', + product_type: 'semiannual', + paywall_name: 'rt.web', + vendor_product_id: 'sixmonth.premium.999', + localized_title: 'Six Months Premium', + web_purchase_url: 'https://example.com', + access_level_id: 'premium', + paywall_ab_test_name: 'rt.web', + subscription: { + period: { + unit: 'month', + number_of_units: 6, + }, + group_identifier: '20770576', + localized_period: '6 months', + offer: null, + }, + localized_description: 'Six Months Premium Description', + adapty_product_id: '0f2e86da-4d6f-435e-bc8f-9f2d1c265a27', + paywall_variation_id: '5b4f588f-1ea3-4000-9de9-0e82e2fe7a48', + price: { + currency_symbol: '$', + localized_string: '$14.99', + currency_code: 'USD', + amount: 14.99, + }, + is_family_shareable: false, + paywall_product_index: 0, + }, + id: 'paywall_view_did_finish_web_payment_navigation', +} as const; + +/** + * Common product data for yearly premium + */ +export const COMMON_PRODUCT_YEARLY = { + vendor_product_id: 'yearly.premium.6999', + adapty_product_id: '4f930955-b0e4-47c3-8bb9-abd1bbdccabd', + access_level_id: 'premium', + product_type: 'annual', + localized_title: '1 Year Premium', + localized_description: '1 Year Premium Description', + region_code: 'US', + is_family_shareable: false, + paywall_product_index: 1, + paywall_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + paywall_ab_test_name: 'rt.Short.Overlay.Video.DarkMode.Toggle2 (Copy)', + paywall_variation_id: '3968c273-f247-4b9f-bd90-305be39d6414', + price: { + amount: 69.99, + currency_code: 'USD', + currency_symbol: '$', + localized_string: '$69.99', + }, + subscription: { + period: { + unit: 'year', + number_of_units: 1, + }, + localized_period: '1 year', + group_identifier: '20770576', + offer: { + offer_identifier: { + type: 'introductory', + }, + phases: [ + { + number_of_periods: 1, + payment_mode: 'free_trial', + localized_number_of_periods: '1 month', + localized_subscription_period: '1 month', + subscription_period: { + unit: 'month', + number_of_units: 1, + }, + price: { + amount: 0, + currency_code: 'USD', + localized_string: '$0.00', + }, + }, + ], + }, + }, +} as const; diff --git a/src/__tests__/integration/ui/paywall/paywall-event-emitter.utils.ts b/src/__tests__/integration/ui/paywall/paywall-event-emitter.utils.ts new file mode 100644 index 00000000..eb940b7e --- /dev/null +++ b/src/__tests__/integration/ui/paywall/paywall-event-emitter.utils.ts @@ -0,0 +1,459 @@ +import { $bridge } from '@/bridge'; + +/** + * Paywall Event Emitters for Testing + * + * These functions emit mock native paywall events in the correct format (snake_case) + * to test event handling in PaywallViewController. + */ + +/** + * Emits mock paywall product selected event for testing + */ +export function emitPaywallProductSelectedEvent( + viewId: string, + productId: string, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload = { + id: 'paywall_view_did_select_product', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + product_id: productId, + }; + + emitter.emit('paywall_view_did_select_product', JSON.stringify(payload)); +} + +/** + * Emits mock paywall user action event for testing + * Universal function for close/system_back/open_url/custom actions + */ +export function emitPaywallUserActionEvent( + viewId: string, + actionType: 'close' | 'system_back' | 'open_url' | 'custom', + actionValue: string | undefined, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const action: Record = { + type: actionType, + }; + + if (actionValue !== undefined) { + action['value'] = actionValue; + } + + const payload = { + id: 'paywall_view_did_perform_action', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + action, + }; + + emitter.emit('paywall_view_did_perform_action', JSON.stringify(payload)); +} + +/** + * Emits mock paywall purchase started event for testing + */ +export function emitPaywallPurchaseStartedEvent( + viewId: string, + product: Record, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload = { + id: 'paywall_view_did_start_purchase', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + product, + }; + + emitter.emit('paywall_view_did_start_purchase', JSON.stringify(payload)); +} + +/** + * Emits mock paywall purchase completed event for testing + */ +export function emitPaywallPurchaseCompletedEvent( + viewId: string, + purchasedResult: Record, + product: Record, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload = { + id: 'paywall_view_did_finish_purchase', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + purchased_result: purchasedResult, + product, + }; + + emitter.emit('paywall_view_did_finish_purchase', JSON.stringify(payload)); +} + +/** + * Emits mock paywall purchase failed event for testing + */ +export function emitPaywallPurchaseFailedEvent( + viewId: string, + error: { adapty_code: number; message: string }, + product: Record, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload = { + id: 'paywall_view_did_fail_purchase', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + error, + product, + }; + + emitter.emit('paywall_view_did_fail_purchase', JSON.stringify(payload)); +} + +/** + * Emits mock paywall restore started event for testing + */ +export function emitPaywallRestoreStartedEvent( + viewId: string, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload = { + id: 'paywall_view_did_start_restore', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + }; + + emitter.emit('paywall_view_did_start_restore', JSON.stringify(payload)); +} + +/** + * Emits mock paywall restore completed event for testing + */ +export function emitPaywallRestoreCompletedEvent( + viewId: string, + profile: Record, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload = { + id: 'paywall_view_did_finish_restore', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + profile, + }; + + emitter.emit('paywall_view_did_finish_restore', JSON.stringify(payload)); +} + +/** + * Emits mock paywall restore failed event for testing + */ +export function emitPaywallRestoreFailedEvent( + viewId: string, + error: { adapty_code: number; message: string }, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload = { + id: 'paywall_view_did_fail_restore', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + error, + }; + + emitter.emit('paywall_view_did_fail_restore', JSON.stringify(payload)); +} + +/** + * Emits mock paywall view appeared event for testing + */ +export function emitPaywallViewAppearedEvent( + viewId: string, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload = { + id: 'paywall_view_did_appear', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + }; + + emitter.emit('paywall_view_did_appear', JSON.stringify(payload)); +} + +/** + * Emits mock paywall view disappeared event for testing + */ +export function emitPaywallViewDisappearedEvent( + viewId: string, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload = { + id: 'paywall_view_did_disappear', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + }; + + emitter.emit('paywall_view_did_disappear', JSON.stringify(payload)); +} + +/** + * Emits mock paywall web payment navigation finished event for testing + */ +export function emitPaywallWebPaymentNavigationFinishedEvent( + viewId: string, + product: Record | undefined, + error: { adapty_code: number; message: string } | undefined, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload: Record = { + id: 'paywall_view_did_finish_web_payment_navigation', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + }; + + if (product !== undefined) { + payload['product'] = product; + } + + if (error !== undefined) { + payload['error'] = error; + } + + emitter.emit( + 'paywall_view_did_finish_web_payment_navigation', + JSON.stringify(payload), + ); +} + +/** + * Emits mock paywall rendering failed event for testing + */ +export function emitPaywallRenderingFailedEvent( + viewId: string, + error: { adapty_code: number; message: string }, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload = { + id: 'paywall_view_did_fail_rendering', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + error, + }; + + emitter.emit('paywall_view_did_fail_rendering', JSON.stringify(payload)); +} + +/** + * Emits mock paywall loading products failed event for testing + */ +export function emitPaywallLoadingProductsFailedEvent( + viewId: string, + error: { adapty_code: number; message: string }, + view: { id: string; placement_id: string; variation_id: string }, +): void { + const bridge = $bridge.testBridge; + + if (!bridge) { + throw new Error('Bridge not initialized'); + } + + const emitter = (bridge as any).testEmitter; + + if (!emitter) { + throw new Error('Mock emitter not available. Ensure mock mode is enabled.'); + } + + const payload = { + id: 'paywall_view_did_fail_loading_products', + view: { + id: viewId, + placement_id: view.placement_id, + variation_id: view.variation_id, + }, + error, + }; + + emitter.emit( + 'paywall_view_did_fail_loading_products', + JSON.stringify(payload), + ); +} diff --git a/src/__tests__/integration/ui/paywall/paywall-view-controller-events.test.ts b/src/__tests__/integration/ui/paywall/paywall-view-controller-events.test.ts new file mode 100644 index 00000000..87a1a9ae --- /dev/null +++ b/src/__tests__/integration/ui/paywall/paywall-view-controller-events.test.ts @@ -0,0 +1,1685 @@ +/** + * Integration tests for Paywall ViewController events + * + * This file contains tests for platform-independent fields only. + * Platform-specific fields are tested in separate files: + * - ios-paywall-view-controller-events.test.ts - iOS-specific fields (isFamilyShareable, subscriptionGroupIdentifier, appleJwsTransaction) + * - android-paywall-view-controller-events.test.ts - Android-specific fields (basePlanId, renewalType, googlePurchaseToken) + * + * Platform-independent fields tested here: + * - Product: vendorProductId, adaptyId, localizedTitle, localizedDescription, price, subscription.period + * - PurchaseResult: type, profile (structure only) + * - Profile: profileId, subscriptions (structure only, not store values), accessLevels (structure only, not store values) + * - Error: adaptyCode, message + */ + +import { Adapty } from '@/adapty-handler'; +import { AdaptyError } from '@/adapty-error'; +import { ViewController } from '@/ui/view-controller'; +import { EventHandlers } from '@/ui/types'; +import { + createPaywallViewController, + cleanupPaywallViewController, +} from '../setup.utils'; +import { + emitPaywallProductSelectedEvent, + emitPaywallUserActionEvent, + emitPaywallPurchaseStartedEvent, + emitPaywallPurchaseCompletedEvent, + emitPaywallPurchaseFailedEvent, + emitPaywallRestoreStartedEvent, + emitPaywallRestoreCompletedEvent, + emitPaywallRestoreFailedEvent, + emitPaywallViewAppearedEvent, + emitPaywallViewDisappearedEvent, + emitPaywallWebPaymentNavigationFinishedEvent, + emitPaywallRenderingFailedEvent, + emitPaywallLoadingProductsFailedEvent, +} from './paywall-event-emitter.utils'; +import { + PAYWALL_PRODUCT_SELECTED_YEARLY, + PAYWALL_USER_ACTION_CLOSE, + PAYWALL_USER_ACTION_OPEN_URL, + PAYWALL_USER_ACTION_SYSTEM_BACK, + PAYWALL_USER_ACTION_CLOSE_BUTTON, + PAYWALL_PURCHASE_STARTED, + PAYWALL_PURCHASE_COMPLETED_SUCCESS, + PAYWALL_PURCHASE_COMPLETED_CANCELLED, + PAYWALL_PURCHASE_FAILED, + PAYWALL_RESTORE_STARTED, + PAYWALL_RESTORE_COMPLETED_SUCCESS, + PAYWALL_RESTORE_FAILED, + PAYWALL_VIEW_APPEARED, + PAYWALL_VIEW_DISAPPEARED, + PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED, + PAYWALL_RENDERING_FAILED, + PAYWALL_LOADING_PRODUCTS_FAILED, +} from './paywall-bridge-event-samples'; + +describe('ViewController - action mapping isolation', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call ONLY onCloseButtonPress when action.type is "close"', async () => { + // Register all 4 handlers for the same native event + const onCloseHandler: jest.MockedFunction< + EventHandlers['onCloseButtonPress'] + > = jest.fn().mockReturnValue(false); + const onSystemBackHandler: jest.MockedFunction< + EventHandlers['onAndroidSystemBack'] + > = jest.fn().mockReturnValue(false); + const onUrlPressHandler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + const onCustomHandler: jest.MockedFunction< + EventHandlers['onCustomAction'] + > = jest.fn().mockReturnValue(false); + + view.setEventHandlers({ + onCloseButtonPress: onCloseHandler, + onAndroidSystemBack: onSystemBackHandler, + onUrlPress: onUrlPressHandler, + onCustomAction: onCustomHandler, + }); + + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_CLOSE; + + // Emit event with action.type = 'close' + emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view); + + // ONLY onCloseButtonPress should be called + expect(onCloseHandler).toHaveBeenCalledTimes(1); + + // Others should NOT be called + expect(onSystemBackHandler).not.toHaveBeenCalled(); + expect(onUrlPressHandler).not.toHaveBeenCalled(); + expect(onCustomHandler).not.toHaveBeenCalled(); + }); + + it('should call ONLY onUrlPress when action.type is "open_url"', async () => { + const onCloseHandler: jest.MockedFunction< + EventHandlers['onCloseButtonPress'] + > = jest.fn().mockReturnValue(false); + const onUrlPressHandler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + const onCustomHandler: jest.MockedFunction< + EventHandlers['onCustomAction'] + > = jest.fn().mockReturnValue(false); + + view.setEventHandlers({ + onCloseButtonPress: onCloseHandler, + onUrlPress: onUrlPressHandler, + onCustomAction: onCustomHandler, + }); + + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_OPEN_URL; + + // Emit event with action.type = 'open_url' and value = URL + emitPaywallUserActionEvent( + viewId, + 'open_url', + sample.action.value, + sample.view, + ); + + // ONLY onUrlPress should be called with URL + expect(onUrlPressHandler).toHaveBeenCalledTimes(1); + expect(onUrlPressHandler).toHaveBeenCalledWith(sample.action.value); + + // Others should NOT be called + expect(onCloseHandler).not.toHaveBeenCalled(); + expect(onCustomHandler).not.toHaveBeenCalled(); + }); + + it('should call ONLY onAndroidSystemBack when action.type is "system_back"', async () => { + const onCloseHandler: jest.MockedFunction< + EventHandlers['onCloseButtonPress'] + > = jest.fn().mockReturnValue(false); + const onSystemBackHandler: jest.MockedFunction< + EventHandlers['onAndroidSystemBack'] + > = jest.fn().mockReturnValue(false); + const onUrlPressHandler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ + onCloseButtonPress: onCloseHandler, + onAndroidSystemBack: onSystemBackHandler, + onUrlPress: onUrlPressHandler, + }); + + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_SYSTEM_BACK; + + // Emit event with action.type = 'system_back' + emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view); + + // ONLY onAndroidSystemBack should be called + expect(onSystemBackHandler).toHaveBeenCalledTimes(1); + + // Others should NOT be called + expect(onCloseHandler).not.toHaveBeenCalled(); + expect(onUrlPressHandler).not.toHaveBeenCalled(); + }); + + it('should call ONLY onCustomAction when action.type is "custom"', async () => { + const onCloseHandler: jest.MockedFunction< + EventHandlers['onCloseButtonPress'] + > = jest.fn().mockReturnValue(false); + const onCustomHandler: jest.MockedFunction< + EventHandlers['onCustomAction'] + > = jest.fn().mockReturnValue(false); + + view.setEventHandlers({ + onCloseButtonPress: onCloseHandler, + onCustomAction: onCustomHandler, + }); + + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_CLOSE; // Reuse sample but change action type + + // Emit event with action.type = 'custom' and custom value + emitPaywallUserActionEvent( + viewId, + 'custom', + 'custom_action_value', + sample.view, + ); + + // ONLY onCustomAction should be called with the value + expect(onCustomHandler).toHaveBeenCalledTimes(1); + expect(onCustomHandler).toHaveBeenCalledWith('custom_action_value'); + + // onCloseButtonPress should NOT be called + expect(onCloseHandler).not.toHaveBeenCalled(); + }); +}); + +describe('ViewController - onProductSelected event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onProductSelected handler when product is selected', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onProductSelected: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(sample.product_id); + }); + + it('should filter events by viewId', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onProductSelected: handler }); + + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + + // Emit event for DIFFERENT view + emitPaywallProductSelectedEvent( + 'different_view_id', + sample.product_id, + sample.view, + ); + + // Handler should NOT be called + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe('ViewController - onPurchaseStarted event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPurchaseStarted handler when purchase starts', async () => { + // NOTE: This test verifies platform-independent fields only. + // Platform-specific fields (iOS: isFamilyShareable, subscriptionGroupIdentifier; + // Android: basePlanId, renewalType) are tested in platform-specific test files. + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseStarted: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_PURCHASE_STARTED; + + emitPaywallPurchaseStartedEvent(viewId, sample.product, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [product] = handler.mock.calls[0]!; + + // Platform-independent product fields + expect(product).toHaveProperty( + 'vendorProductId', + sample.product.vendor_product_id, + ); + expect(product).toHaveProperty( + 'adaptyId', + sample.product.adapty_product_id, + ); + expect(product).toHaveProperty( + 'localizedTitle', + sample.product.localized_title, + ); + expect(product).toHaveProperty( + 'localizedDescription', + sample.product.localized_description, + ); + expect(product).toHaveProperty( + 'accessLevelId', + sample.product.access_level_id, + ); + expect(product).toHaveProperty('productType', sample.product.product_type); + + // Platform-independent price fields + expect(product).toHaveProperty('price'); + expect(product.price).toHaveProperty('amount', sample.product.price.amount); + expect(product.price).toHaveProperty( + 'currencyCode', + sample.product.price.currency_code, + ); + expect(product.price).toHaveProperty( + 'currencySymbol', + sample.product.price.currency_symbol, + ); + expect(product.price).toHaveProperty( + 'localizedString', + sample.product.price.localized_string, + ); + + // Platform-independent subscription fields + if (product.subscription) { + expect(product.subscription).toHaveProperty('subscriptionPeriod'); + expect(product.subscription.subscriptionPeriod).toHaveProperty( + 'unit', + sample.product.subscription.period.unit, + ); + expect(product.subscription.subscriptionPeriod).toHaveProperty( + 'numberOfUnits', + sample.product.subscription.period.number_of_units, + ); + expect(product.subscription).toHaveProperty( + 'localizedSubscriptionPeriod', + sample.product.subscription.localized_period, + ); + } + }); +}); + +describe('ViewController - onPurchaseCompleted event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPurchaseCompleted handler with success result', async () => { + // NOTE: This test verifies platform-independent fields only. + // Platform-specific fields (iOS: appleJwsTransaction; Android: googlePurchaseToken) + // are tested in platform-specific test files. + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseCompleted: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_PURCHASE_COMPLETED_SUCCESS; + + emitPaywallPurchaseCompletedEvent( + viewId, + sample.purchased_result, + sample.product, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [purchaseResult, product] = handler.mock.calls[0]!; + + // Platform-independent purchase result fields + expect(purchaseResult.type).toBe('success'); + if (purchaseResult.type === 'success') { + expect(purchaseResult).toHaveProperty('profile'); + expect(purchaseResult.profile).toHaveProperty('profileId'); + expect(purchaseResult.profile).toHaveProperty('subscriptions'); + expect(purchaseResult.profile).toHaveProperty('accessLevels'); + } + + // Platform-independent product fields + expect(product).toHaveProperty( + 'vendorProductId', + sample.product.vendor_product_id, + ); + expect(product).toHaveProperty( + 'adaptyId', + sample.product.adapty_product_id, + ); + expect(product).toHaveProperty( + 'localizedTitle', + sample.product.localized_title, + ); + expect(product).toHaveProperty( + 'localizedDescription', + sample.product.localized_description, + ); + }); + + it('should call onPurchaseCompleted handler with user_cancelled result', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onPurchaseCompleted: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_PURCHASE_COMPLETED_CANCELLED; + + emitPaywallPurchaseCompletedEvent( + viewId, + sample.purchased_result, + sample.product, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [purchaseResult] = handler.mock.calls[0]!; + expect(purchaseResult).toHaveProperty('type', 'user_cancelled'); + }); +}); + +describe('ViewController - onPurchaseFailed event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPurchaseFailed handler when purchase fails', async () => { + // NOTE: This test verifies platform-independent fields only. + // Platform-specific product fields are tested in platform-specific test files. + const handler: jest.MockedFunction = jest + .fn() + .mockReturnValue(false); + + view.setEventHandlers({ onPurchaseFailed: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_PURCHASE_FAILED; + + emitPaywallPurchaseFailedEvent( + viewId, + sample.error, + sample.product, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [error, product] = handler.mock.calls[0]!; + + // Platform-independent error fields + expect(error).toBeInstanceOf(AdaptyError); + expect(error.adaptyCode).toBe(sample.error.adapty_code); + expect(error).toHaveProperty('message'); + + // Platform-independent product fields + expect(product).toHaveProperty( + 'vendorProductId', + sample.product.vendor_product_id, + ); + expect(product).toHaveProperty( + 'adaptyId', + sample.product.adapty_product_id, + ); + expect(product).toHaveProperty( + 'localizedTitle', + sample.product.localized_title, + ); + expect(product).toHaveProperty( + 'localizedDescription', + sample.product.localized_description, + ); + }); +}); + +describe('ViewController - onRestoreStarted event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onRestoreStarted handler when restore starts', async () => { + const handler: jest.MockedFunction = jest + .fn() + .mockReturnValue(false); + + view.setEventHandlers({ onRestoreStarted: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_RESTORE_STARTED; + + emitPaywallRestoreStartedEvent(viewId, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(); + }); +}); + +describe('ViewController - onRestoreCompleted event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onRestoreCompleted handler with profile', async () => { + // NOTE: This test verifies platform-independent fields only. + // Platform-specific fields (store: "app_store" vs "play_store", transaction ID formats) + // are tested in platform-specific test files. + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onRestoreCompleted: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_RESTORE_COMPLETED_SUCCESS; + + emitPaywallRestoreCompletedEvent(viewId, sample.profile, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [profile] = handler.mock.calls[0]!; + + // Platform-independent profile fields + expect(profile).toHaveProperty('profileId', sample.profile.profile_id); + expect(profile).toHaveProperty('subscriptions'); + expect(profile).toHaveProperty('accessLevels'); + + // Platform-independent subscription fields (structure only, not store-specific values) + if (profile.subscriptions) { + const subscription = Object.values(profile.subscriptions)[0]; + expect(subscription).toHaveProperty('vendorProductId'); + expect(subscription).toHaveProperty('isActive'); + expect(subscription).toHaveProperty('activatedAt'); + expect(subscription).toHaveProperty('store'); // Only check presence, not value + } + + // Platform-independent access level fields (structure only, not store-specific values) + if (profile.accessLevels) { + const accessLevel = Object.values(profile.accessLevels)[0]; + expect(accessLevel).toHaveProperty('id'); + expect(accessLevel).toHaveProperty('isActive'); + expect(accessLevel).toHaveProperty('vendorProductId'); + expect(accessLevel).toHaveProperty('store'); // Only check presence, not value + } + }); +}); + +describe('ViewController - onRestoreFailed event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onRestoreFailed handler when restore fails', async () => { + const handler: jest.MockedFunction = jest + .fn() + .mockReturnValue(false); + + view.setEventHandlers({ onRestoreFailed: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_RESTORE_FAILED; + + emitPaywallRestoreFailedEvent(viewId, sample.error, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [error] = handler.mock.calls[0]!; + expect(error).toBeInstanceOf(AdaptyError); + expect(error.adaptyCode).toBe(sample.error.adapty_code); + }); +}); + +describe('ViewController - onCloseButtonPress event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onCloseButtonPress handler when close button is pressed', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onCloseButtonPress: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_CLOSE_BUTTON; + + emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(); + }); +}); + +describe('ViewController - onAndroidSystemBack event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onAndroidSystemBack handler when system back is pressed', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onAndroidSystemBack: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_SYSTEM_BACK; + + emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(); + }); +}); + +describe('ViewController - onUrlPress event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onUrlPress handler with URL when URL is pressed', async () => { + const handler: jest.MockedFunction = jest + .fn() + .mockReturnValue(false); + + view.setEventHandlers({ onUrlPress: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_OPEN_URL; + + emitPaywallUserActionEvent( + viewId, + 'open_url', + sample.action.value, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(sample.action.value); + }); +}); + +describe('ViewController - onCustomAction event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onCustomAction handler with action value', async () => { + const handler: jest.MockedFunction = jest + .fn() + .mockReturnValue(false); + + view.setEventHandlers({ onCustomAction: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_CLOSE; // Reuse sample + + const customValue = 'my_custom_action'; + emitPaywallUserActionEvent(viewId, 'custom', customValue, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(customValue); + }); +}); + +describe('ViewController - onPaywallShown event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPaywallShown handler when paywall appears', async () => { + const handler: jest.MockedFunction = jest + .fn() + .mockReturnValue(false); + + view.setEventHandlers({ onPaywallShown: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_VIEW_APPEARED; + + emitPaywallViewAppearedEvent(viewId, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(); + }); +}); + +describe('ViewController - onPaywallClosed event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPaywallClosed handler when paywall disappears', async () => { + const handler: jest.MockedFunction = jest + .fn() + .mockReturnValue(false); + + view.setEventHandlers({ onPaywallClosed: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_VIEW_DISAPPEARED; + + emitPaywallViewDisappearedEvent(viewId, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(); + }); +}); + +describe('ViewController - onRenderingFailed event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onRenderingFailed handler when rendering fails', async () => { + const handler: jest.MockedFunction = + jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onRenderingFailed: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_RENDERING_FAILED; + + emitPaywallRenderingFailedEvent(viewId, sample.error, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [error] = handler.mock.calls[0]!; + expect(error).toBeInstanceOf(AdaptyError); + expect(error.adaptyCode).toBe(sample.error.adapty_code); + }); +}); + +describe('ViewController - onLoadingProductsFailed event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onLoadingProductsFailed handler when loading products fails', async () => { + const handler: jest.MockedFunction< + EventHandlers['onLoadingProductsFailed'] + > = jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onLoadingProductsFailed: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_LOADING_PRODUCTS_FAILED; + + emitPaywallLoadingProductsFailedEvent(viewId, sample.error, sample.view); + + expect(handler).toHaveBeenCalledTimes(1); + const [error] = handler.mock.calls[0]!; + expect(error).toBeInstanceOf(AdaptyError); + expect(error.adaptyCode).toBe(sample.error.adapty_code); + }); +}); + +describe('ViewController - onWebPaymentNavigationFinished event', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onWebPaymentNavigationFinished handler with product', async () => { + // NOTE: This test verifies platform-independent fields only. + // Platform-specific product fields are tested in platform-specific test files. + const handler: jest.MockedFunction< + EventHandlers['onWebPaymentNavigationFinished'] + > = jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onWebPaymentNavigationFinished: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED; + + emitPaywallWebPaymentNavigationFinishedEvent( + viewId, + sample.product, + undefined, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [product, error] = handler.mock.calls[0]!; + + // Platform-independent product fields + expect(product).toBeDefined(); + if (product) { + expect(product).toHaveProperty( + 'vendorProductId', + sample.product.vendor_product_id, + ); + expect(product).toHaveProperty( + 'adaptyId', + sample.product.adapty_product_id, + ); + expect(product).toHaveProperty( + 'localizedTitle', + sample.product.localized_title, + ); + expect(product).toHaveProperty( + 'localizedDescription', + sample.product.localized_description, + ); + expect(product).toHaveProperty('price'); + if (product.price) { + expect(product.price).toHaveProperty( + 'amount', + sample.product.price.amount, + ); + expect(product.price).toHaveProperty( + 'currencyCode', + sample.product.price.currency_code, + ); + } + + // Platform-independent subscription fields + if (product.subscription) { + expect(product.subscription).toHaveProperty('subscriptionPeriod'); + expect(product.subscription.subscriptionPeriod).toHaveProperty( + 'unit', + sample.product.subscription.period.unit, + ); + expect(product.subscription.subscriptionPeriod).toHaveProperty( + 'numberOfUnits', + sample.product.subscription.period.number_of_units, + ); + } + } + + expect(error).toBeUndefined(); + }); + + it('should call onWebPaymentNavigationFinished handler with error', async () => { + const handler: jest.MockedFunction< + EventHandlers['onWebPaymentNavigationFinished'] + > = jest.fn().mockReturnValue(false); + + view.setEventHandlers({ onWebPaymentNavigationFinished: handler }); + + const viewId = (view as any).id; + const sample = PAYWALL_WEB_PAYMENT_NAVIGATION_FINISHED; + + const mockError = { adapty_code: 999, message: 'Web payment error' }; + emitPaywallWebPaymentNavigationFinishedEvent( + viewId, + undefined, + mockError, + sample.view, + ); + + expect(handler).toHaveBeenCalledTimes(1); + const [product, error] = handler.mock.calls[0]!; + expect(product).toBeUndefined(); + expect(error).toBeInstanceOf(AdaptyError); + }); +}); + +describe('ViewController - event viewId filtering', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should ignore events when viewId does not match', async () => { + // Create mock handlers for all event types + const onProductSelectedHandler = jest.fn().mockReturnValue(false); + const onPurchaseStartedHandler = jest.fn().mockReturnValue(false); + const onPurchaseCompletedHandler = jest.fn().mockReturnValue(false); + const onRestoreStartedHandler = jest.fn().mockReturnValue(false); + const onCloseHandler = jest.fn().mockReturnValue(false); + const onPaywallShownHandler = jest.fn().mockReturnValue(false); + + // Register all handlers + view.setEventHandlers({ + onProductSelected: onProductSelectedHandler, + onPurchaseStarted: onPurchaseStartedHandler, + onPurchaseCompleted: onPurchaseCompletedHandler, + onRestoreStarted: onRestoreStartedHandler, + onCloseButtonPress: onCloseHandler, + onPaywallShown: onPaywallShownHandler, + }); + + // Use a different viewId + const wrongViewId = 'wrong_view_id_12345'; + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + + // Emit all event types with wrong viewId + emitPaywallProductSelectedEvent( + wrongViewId, + sample.product_id, + sample.view, + ); + emitPaywallPurchaseStartedEvent( + wrongViewId, + PAYWALL_PURCHASE_STARTED.product, + sample.view, + ); + emitPaywallPurchaseCompletedEvent( + wrongViewId, + PAYWALL_PURCHASE_COMPLETED_SUCCESS.purchased_result, + PAYWALL_PURCHASE_COMPLETED_SUCCESS.product, + sample.view, + ); + emitPaywallRestoreStartedEvent(wrongViewId, sample.view); + emitPaywallUserActionEvent(wrongViewId, 'close', undefined, sample.view); + emitPaywallViewAppearedEvent(wrongViewId, sample.view); + + // Verify that NONE of the handlers were called + expect(onProductSelectedHandler).not.toHaveBeenCalled(); + expect(onPurchaseStartedHandler).not.toHaveBeenCalled(); + expect(onPurchaseCompletedHandler).not.toHaveBeenCalled(); + expect(onRestoreStartedHandler).not.toHaveBeenCalled(); + expect(onCloseHandler).not.toHaveBeenCalled(); + expect(onPaywallShownHandler).not.toHaveBeenCalled(); + }); +}); + +describe('ViewController - multiple views isolation', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should isolate event handlers between view instances', async () => { + // Create a second view using the SAME adapty instance + const { paywall: paywall2 } = await createPaywallViewController(); + const view2 = await (view.constructor as any).create(paywall2, {}); + + try { + const viewId1 = (view as any).id; + const viewId2 = (view2 as any).id; + + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + + // Create handlers for both views + const handler1 = jest.fn().mockReturnValue(false); + const handler2 = jest.fn().mockReturnValue(false); + + // Register handlers on both views + const unsubscribe1 = view.setEventHandlers({ + onProductSelected: handler1, + }); + view2.setEventHandlers({ onProductSelected: handler2 }); + + // Emit events to both views - both should receive + emitPaywallProductSelectedEvent(viewId1, sample.product_id, sample.view); + emitPaywallProductSelectedEvent(viewId2, sample.product_id, sample.view); + + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + + // Clear mocks + handler1.mockClear(); + handler2.mockClear(); + + // Unsubscribe first view + unsubscribe1(); + + // Emit events again + emitPaywallProductSelectedEvent(viewId1, sample.product_id, sample.view); + emitPaywallProductSelectedEvent(viewId2, sample.product_id, sample.view); + + // First view should not receive (unsubscribed) + expect(handler1).not.toHaveBeenCalled(); + // Second view should still receive (not affected by first view's unsubscribe) + expect(handler2).toHaveBeenCalledTimes(1); + } finally { + // Cleanup second view + // view2 cleanup happens through adapty.removeAllListeners + } + }); +}); + +describe('ViewController - unsubscribe functionality', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should unsubscribe all handlers using returned unsubscribe function', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + + // Set up multiple handlers + const onProductSelectedHandler = jest.fn().mockReturnValue(false); + const onPurchaseStartedHandler = jest.fn().mockReturnValue(false); + const onCloseHandler = jest.fn().mockReturnValue(false); + + const unsubscribe = view.setEventHandlers({ + onProductSelected: onProductSelectedHandler, + onPurchaseStarted: onPurchaseStartedHandler, + onCloseButtonPress: onCloseHandler, + }); + + // Emit events BEFORE unsubscribe - handlers should be called + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + emitPaywallPurchaseStartedEvent( + viewId, + PAYWALL_PURCHASE_STARTED.product, + sample.view, + ); + emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view); + + expect(onProductSelectedHandler).toHaveBeenCalledTimes(1); + expect(onPurchaseStartedHandler).toHaveBeenCalledTimes(1); + expect(onCloseHandler).toHaveBeenCalledTimes(1); + + // Clear mocks + onProductSelectedHandler.mockClear(); + onPurchaseStartedHandler.mockClear(); + onCloseHandler.mockClear(); + + // Call unsubscribe + unsubscribe(); + // Idempotency: second call should not throw + expect(() => unsubscribe()).not.toThrow(); + + // Emit events AFTER unsubscribe - handlers should NOT be called + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + emitPaywallPurchaseStartedEvent( + viewId, + PAYWALL_PURCHASE_STARTED.product, + sample.view, + ); + emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view); + + expect(onProductSelectedHandler).not.toHaveBeenCalled(); + expect(onPurchaseStartedHandler).not.toHaveBeenCalled(); + expect(onCloseHandler).not.toHaveBeenCalled(); + }); + + it('should allow re-subscribing after unsubscribe', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + + // First subscription + const onProductSelectedHandler1 = jest.fn().mockReturnValue(false); + const unsubscribe1 = view.setEventHandlers({ + onProductSelected: onProductSelectedHandler1, + }); + + // Emit event - should be handled + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + expect(onProductSelectedHandler1).toHaveBeenCalledTimes(1); + + // Unsubscribe + unsubscribe1(); + onProductSelectedHandler1.mockClear(); + + // Emit event - should NOT be handled + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + expect(onProductSelectedHandler1).not.toHaveBeenCalled(); + + // Re-subscribe with new handler + const onProductSelectedHandler2 = jest.fn().mockReturnValue(false); + view.setEventHandlers({ + onProductSelected: onProductSelectedHandler2, + }); + + // Emit event - new handler should be called + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + expect(onProductSelectedHandler1).not.toHaveBeenCalled(); // Old handler still not called + expect(onProductSelectedHandler2).toHaveBeenCalledTimes(1); // New handler called + }); +}); + +describe('ViewController - dismiss on handler return value', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call dismiss when any handler returns true', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + + // Test onProductSelected returning true + const onProductSelectedHandler = jest.fn().mockReturnValue(true); + const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(); + + view.setEventHandlers({ onProductSelected: onProductSelectedHandler }); + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + expect(onProductSelectedHandler).toHaveBeenCalledTimes(1); + expect(dismissSpy).toHaveBeenCalledTimes(1); + + // Reset + onProductSelectedHandler.mockClear(); + dismissSpy.mockClear(); + + // Test onCloseButtonPress returning true + const onCloseHandler = jest.fn().mockReturnValue(true); + view.setEventHandlers({ onCloseButtonPress: onCloseHandler }); + emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view); + expect(onCloseHandler).toHaveBeenCalledTimes(1); + expect(dismissSpy).toHaveBeenCalledTimes(1); + + // Reset + onCloseHandler.mockClear(); + dismissSpy.mockClear(); + + // Test onPurchaseCompleted returning true + const onPurchaseCompletedHandler = jest.fn().mockReturnValue(true); + view.setEventHandlers({ onPurchaseCompleted: onPurchaseCompletedHandler }); + emitPaywallPurchaseCompletedEvent( + viewId, + PAYWALL_PURCHASE_COMPLETED_SUCCESS.purchased_result, + PAYWALL_PURCHASE_COMPLETED_SUCCESS.product, + sample.view, + ); + expect(onPurchaseCompletedHandler).toHaveBeenCalledTimes(1); + expect(dismissSpy).toHaveBeenCalledTimes(1); + + dismissSpy.mockRestore(); + }); + + it('should NOT call dismiss when handlers return false', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(); + + // Test onProductSelected returning false + const onProductSelectedHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onProductSelected: onProductSelectedHandler }); + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + expect(onProductSelectedHandler).toHaveBeenCalledTimes(1); + expect(dismissSpy).not.toHaveBeenCalled(); + + onProductSelectedHandler.mockClear(); + + // Test onCloseButtonPress returning false + const onCloseHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onCloseButtonPress: onCloseHandler }); + emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view); + expect(onCloseHandler).toHaveBeenCalledTimes(1); + expect(dismissSpy).not.toHaveBeenCalled(); + + dismissSpy.mockRestore(); + }); + + it('should NOT call dismiss when handlers return undefined', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(); + + // Test onProductSelected returning undefined + const onProductSelectedHandler = jest.fn().mockReturnValue(undefined); + view.setEventHandlers({ onProductSelected: onProductSelectedHandler }); + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + expect(onProductSelectedHandler).toHaveBeenCalledTimes(1); + expect(dismissSpy).not.toHaveBeenCalled(); + + onProductSelectedHandler.mockClear(); + + // Test onRestoreStarted returning undefined + const onRestoreStartedHandler = jest.fn().mockReturnValue(undefined); + view.setEventHandlers({ onRestoreStarted: onRestoreStartedHandler }); + emitPaywallRestoreStartedEvent(viewId, sample.view); + expect(onRestoreStartedHandler).toHaveBeenCalledTimes(1); + expect(dismissSpy).not.toHaveBeenCalled(); + + dismissSpy.mockRestore(); + }); +}); + +describe('ViewController - dismiss cleanup', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should unsubscribe all event listeners after dismiss', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + + // Set up handlers for multiple event types + const onProductSelectedHandler = jest.fn().mockReturnValue(false); + const onPurchaseStartedHandler = jest.fn().mockReturnValue(false); + const onCloseHandler = jest.fn().mockReturnValue(false); + const onRestoreStartedHandler = jest.fn().mockReturnValue(false); + + view.setEventHandlers({ + onProductSelected: onProductSelectedHandler, + onPurchaseStarted: onPurchaseStartedHandler, + onCloseButtonPress: onCloseHandler, + onRestoreStarted: onRestoreStartedHandler, + }); + + // Emit events BEFORE dismiss - handlers should be called + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + emitPaywallPurchaseStartedEvent( + viewId, + PAYWALL_PURCHASE_STARTED.product, + sample.view, + ); + + expect(onProductSelectedHandler).toHaveBeenCalledTimes(1); + expect(onPurchaseStartedHandler).toHaveBeenCalledTimes(1); + + // Clear mock call history + onProductSelectedHandler.mockClear(); + onPurchaseStartedHandler.mockClear(); + onCloseHandler.mockClear(); + onRestoreStartedHandler.mockClear(); + + // Call dismiss + await view.dismiss(); + + // Emit onPaywallClosed event - this triggers cleanup via internal handler + emitPaywallViewDisappearedEvent(viewId, sample.view); + + // Emit events AFTER cleanup - handlers should NOT be called + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + emitPaywallPurchaseStartedEvent( + viewId, + PAYWALL_PURCHASE_STARTED.product, + sample.view, + ); + emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view); + emitPaywallRestoreStartedEvent(viewId, sample.view); + + // Verify that NONE of the handlers were called after cleanup + expect(onProductSelectedHandler).not.toHaveBeenCalled(); + expect(onPurchaseStartedHandler).not.toHaveBeenCalled(); + expect(onCloseHandler).not.toHaveBeenCalled(); + expect(onRestoreStartedHandler).not.toHaveBeenCalled(); + }); + + it('should not throw error when dismiss is called without any handlers set', async () => { + // Don't set any custom handlers, only default ones exist + await expect(view.dismiss()).resolves.not.toThrow(); + }); + + it('should not throw error when dismiss is called multiple times', async () => { + await view.dismiss(); + // Second dismiss should not throw + await expect(view.dismiss()).resolves.not.toThrow(); + }); +}); + +describe('ViewController - default event handlers', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should auto-dismiss paywall when onCloseButtonPress event is emitted with default handler', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_CLOSE; + + // Spy on dismiss method to verify it's called + const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(); + + // Emit close event WITHOUT setting custom handler + // Default handler (onCloseButtonPress: () => true) should be active from create() + emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view); + + // Verify dismiss was called due to default handler returning true + expect(dismissSpy).toHaveBeenCalledTimes(1); + + dismissSpy.mockRestore(); + }); + + it('should auto-dismiss paywall when onAndroidSystemBack event is emitted with default handler', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_SYSTEM_BACK; + + // Spy on dismiss method to verify it's called + const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(); + + // Emit system_back event WITHOUT setting custom handler + // Default handler (onAndroidSystemBack: () => true) should be active from create() + emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view); + + // Verify dismiss was called due to default handler returning true + expect(dismissSpy).toHaveBeenCalledTimes(1); + + dismissSpy.mockRestore(); + }); + + it('should auto-dismiss paywall when onRenderingFailed event is emitted with default handler', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_RENDERING_FAILED; + + // Spy on dismiss method to verify it's called + const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(); + + // Emit rendering_failed event WITHOUT setting custom handler + // Default handler (onRenderingFailed: () => true) should be active from create() + emitPaywallRenderingFailedEvent(viewId, sample.error, sample.view); + + // Verify dismiss was called due to default handler returning true + expect(dismissSpy).toHaveBeenCalledTimes(1); + + dismissSpy.mockRestore(); + }); + + it('should allow overriding default onCloseButtonPress handler', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_CLOSE; + + const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(); + + // Override default handler with one that returns false + const customHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onCloseButtonPress: customHandler }); + + // Emit close event + emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view); + + // Custom handler should be called + expect(customHandler).toHaveBeenCalledTimes(1); + + // Dismiss should NOT be called because custom handler returned false + expect(dismissSpy).not.toHaveBeenCalled(); + + dismissSpy.mockRestore(); + }); + + it('should allow overriding default onAndroidSystemBack handler', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_USER_ACTION_SYSTEM_BACK; + + const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(); + + // Override default handler with one that returns false + const customHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onAndroidSystemBack: customHandler }); + + // Emit system_back event + emitPaywallUserActionEvent(viewId, 'system_back', undefined, sample.view); + + // Custom handler should be called + expect(customHandler).toHaveBeenCalledTimes(1); + + // Dismiss should NOT be called because custom handler returned false + expect(dismissSpy).not.toHaveBeenCalled(); + + dismissSpy.mockRestore(); + }); + + it('should allow overriding default onRenderingFailed handler', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_RENDERING_FAILED; + + const dismissSpy = jest.spyOn(view, 'dismiss').mockResolvedValue(); + + // Override default handler with one that returns false + const customHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onRenderingFailed: customHandler }); + + // Emit rendering_failed event + emitPaywallRenderingFailedEvent(viewId, sample.error, sample.view); + + // Custom handler should be called + expect(customHandler).toHaveBeenCalledTimes(1); + + // Dismiss should NOT be called because custom handler returned false + expect(dismissSpy).not.toHaveBeenCalled(); + + dismissSpy.mockRestore(); + }); +}); + +describe('ViewController - setEventHandlers merge behavior', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should preserve previously set handlers when adding new ones', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + + // Set first handler + const onProductSelectedHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onProductSelected: onProductSelectedHandler }); + + // Set second handler - onProductSelected should still be active + const onPurchaseStartedHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onPurchaseStarted: onPurchaseStartedHandler }); + + // Emit events for both handlers + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + emitPaywallPurchaseStartedEvent( + viewId, + PAYWALL_PURCHASE_STARTED.product, + sample.view, + ); + + // Both handlers should have been called + expect(onProductSelectedHandler).toHaveBeenCalledTimes(1); + expect(onProductSelectedHandler).toHaveBeenCalledWith(sample.product_id); + expect(onPurchaseStartedHandler).toHaveBeenCalledTimes(1); + }); + + it('should replace handler when setting same event type again', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + + // Set first onProductSelected handler + const onProductSelectedHandler1 = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onProductSelected: onProductSelectedHandler1 }); + + // Replace with second onProductSelected handler + const onProductSelectedHandler2 = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onProductSelected: onProductSelectedHandler2 }); + + // Emit product selected event + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + + // Only the second handler should be called + expect(onProductSelectedHandler1).not.toHaveBeenCalled(); + expect(onProductSelectedHandler2).toHaveBeenCalledTimes(1); + expect(onProductSelectedHandler2).toHaveBeenCalledWith(sample.product_id); + }); + + it('should preserve multiple handlers across successive setEventHandlers calls', async () => { + const viewId = (view as any).id; + const sample = PAYWALL_PRODUCT_SELECTED_YEARLY; + + // Set handlers one by one + const onProductSelectedHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onProductSelected: onProductSelectedHandler }); + + const onPurchaseStartedHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onPurchaseStarted: onPurchaseStartedHandler }); + + const onRestoreStartedHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onRestoreStarted: onRestoreStartedHandler }); + + const onCloseHandler = jest.fn().mockReturnValue(false); + view.setEventHandlers({ onCloseButtonPress: onCloseHandler }); + + // Emit all events + emitPaywallProductSelectedEvent(viewId, sample.product_id, sample.view); + emitPaywallPurchaseStartedEvent( + viewId, + PAYWALL_PURCHASE_STARTED.product, + sample.view, + ); + emitPaywallRestoreStartedEvent(viewId, sample.view); + emitPaywallUserActionEvent(viewId, 'close', undefined, sample.view); + + // All handlers should have been called + expect(onProductSelectedHandler).toHaveBeenCalledTimes(1); + expect(onPurchaseStartedHandler).toHaveBeenCalledTimes(1); + expect(onRestoreStartedHandler).toHaveBeenCalledTimes(1); + expect(onCloseHandler).toHaveBeenCalledTimes(1); + }); +}); + +describe('ViewController - onPaywallClosed after user action close', () => { + let adapty: Adapty; + let view: ViewController; + + beforeEach(async () => { + const result = await createPaywallViewController(); + adapty = result.adapty; + view = result.view; + }); + + afterEach(() => { + cleanupPaywallViewController(view, adapty); + }); + + it('should call onPaywallClosed handler even after close button triggers dismiss', async () => { + const viewId = (view as any).id; + const closeButtonSample = PAYWALL_USER_ACTION_CLOSE_BUTTON; + const closedSample = PAYWALL_VIEW_DISAPPEARED; + + // Set up handlers + const onCloseButtonPressHandler: jest.MockedFunction< + EventHandlers['onCloseButtonPress'] + > = jest.fn().mockReturnValue(true); // Returns true to trigger dismiss + const onPaywallClosedHandler: jest.MockedFunction< + EventHandlers['onPaywallClosed'] + > = jest.fn().mockReturnValue(false); + + // Spy on dismiss WITHOUT mocking - let it execute normally + const dismissSpy = jest.spyOn(view, 'dismiss'); + + view.setEventHandlers({ + onCloseButtonPress: onCloseButtonPressHandler, + onPaywallClosed: onPaywallClosedHandler, + }); + + // 1. User presses close button - this should trigger dismiss + emitPaywallUserActionEvent( + viewId, + 'close', + undefined, + closeButtonSample.view, + ); + + expect(onCloseButtonPressHandler).toHaveBeenCalledTimes(1); + expect(dismissSpy).toHaveBeenCalledTimes(1); + + // 2. Wait for dismiss to complete (including removeAllListeners) + await dismissSpy.mock.results[0]?.value; + + // 3. Native code sends onPaywallClosed event after paywall actually closes + emitPaywallViewDisappearedEvent(viewId, closedSample.view); + + // VERIFICATION: onPaywallClosed handler should be called + // Cleanup happens via internal handler AFTER this client handler executes + expect(onPaywallClosedHandler).toHaveBeenCalledTimes(1); + + dismissSpy.mockRestore(); + }); +}); diff --git a/src/__tests__/integration/ui/setup.utils.ts b/src/__tests__/integration/ui/setup.utils.ts index d06e1c24..aeb56a2a 100644 --- a/src/__tests__/integration/ui/setup.utils.ts +++ b/src/__tests__/integration/ui/setup.utils.ts @@ -1,6 +1,9 @@ import { Adapty } from '@/adapty-handler'; import { createOnboardingView } from '@/ui/create-onboarding-view'; import { OnboardingViewController } from '@/ui/onboarding-view-controller'; +import { createPaywallView } from '@/ui/create-paywall-view'; +import { ViewController } from '@/ui/view-controller'; +import { AdaptyPaywall } from '@/types'; import { createAdaptyInstance, cleanupAdapty, @@ -31,3 +34,30 @@ export function cleanupOnboardingViewController( void view; // Suppress unused variable warning cleanupAdapty(adapty); } + +/** + * Creates PaywallViewController for testing + */ +export async function createPaywallViewController(): Promise<{ + adapty: Adapty; + view: ViewController; + paywall: AdaptyPaywall; +}> { + const adapty = await createAdaptyInstance(); + const paywall = await adapty.getPaywall('test_placement'); + const view = await createPaywallView(paywall); + + return { adapty, view, paywall }; +} + +/** + * Cleanup PaywallViewController and Adapty instance + */ +export function cleanupPaywallViewController( + view: ViewController, + adapty: Adapty, +): void { + // View cleanup happens automatically on dismiss + void view; // Suppress unused variable warning + cleanupAdapty(adapty); +} diff --git a/src/coders/adapty-configuration.test.ts b/src/coders/adapty-configuration.test.ts index f574fe72..63f2327e 100644 --- a/src/coders/adapty-configuration.test.ts +++ b/src/coders/adapty-configuration.test.ts @@ -50,6 +50,7 @@ describe('AdaptyConfigurationCoder', () => { ios: { idfaCollectionDisabled: true, appAccountToken: 'token123', + clearDataOnBackup: true, }, android: { adIdCollectionDisabled: true, @@ -71,6 +72,7 @@ describe('AdaptyConfigurationCoder', () => { }, observer_mode: true, ip_address_collection_disabled: true, + clear_data_on_backup: true, log_level: 'verbose', server_cluster: 'eu', backend_proxy_host: 'proxy.example.com', @@ -184,6 +186,20 @@ describe('AdaptyConfigurationCoder', () => { expect(result.log_level).toBeUndefined(); }); + it('should handle clearDataOnBackup parameter conditionally', () => { + const paramsWithout = {}; + const resultWithout = coder.encode(apiKey, paramsWithout); + expect(resultWithout.clear_data_on_backup).toBeUndefined(); + + const paramsWithFalse = { ios: { clearDataOnBackup: false } }; + const resultWithFalse = coder.encode(apiKey, paramsWithFalse); + expect(resultWithFalse.clear_data_on_backup).toBe(false); + + const paramsWithTrue = { ios: { clearDataOnBackup: true } }; + const resultWithTrue = coder.encode(apiKey, paramsWithTrue); + expect(resultWithTrue.clear_data_on_backup).toBe(true); + }); + it('should prefer params media cache over default', () => { const params = { mediaCache: { diff --git a/src/coders/adapty-configuration.ts b/src/coders/adapty-configuration.ts index f4192e69..9a399878 100644 --- a/src/coders/adapty-configuration.ts +++ b/src/coders/adapty-configuration.ts @@ -56,6 +56,9 @@ export class AdaptyConfigurationCoder { app_account_token: params.ios.appAccountToken, }; } + if (params.ios?.clearDataOnBackup !== undefined) { + config['clear_data_on_backup'] = params.ios.clearDataOnBackup; + } } if (Platform.OS === 'android') { diff --git a/src/coders/adapty-ui-create-onboarding-view-params.ts b/src/coders/adapty-ui-create-onboarding-view-params.ts index bc0417d1..c6817295 100644 --- a/src/coders/adapty-ui-create-onboarding-view-params.ts +++ b/src/coders/adapty-ui-create-onboarding-view-params.ts @@ -1,8 +1,10 @@ import { CreateOnboardingViewParamsInput } from '@/ui/types'; +import { Req } from '@/types/schema'; -type Serializable = { - external_urls_presentation?: string; -}; +type Serializable = Pick< + Req['AdaptyUICreateOnboardingView.Request'], + 'external_urls_presentation' +>; export class AdaptyUICreateOnboardingViewParamsCoder { encode(data: CreateOnboardingViewParamsInput): Serializable { diff --git a/src/coders/parse-paywall.ts b/src/coders/parse-paywall.ts new file mode 100644 index 00000000..c1a8b59f --- /dev/null +++ b/src/coders/parse-paywall.ts @@ -0,0 +1,224 @@ +import { AdaptyError } from '@/adapty-error'; +import { LogContext } from '../logger'; +import { ErrorConverter } from './error-coder'; +import type { Converter } from './types'; +import { AdaptyNativeErrorCoder } from './adapty-native-error'; +import { AdaptyPaywallProductCoder } from './adapty-paywall-product'; +import { AdaptyProfileCoder } from './adapty-profile'; +import { AdaptyPurchaseResultCoder } from './adapty-purchase-result'; +import type { + AdaptyPaywallProduct, + AdaptyProfile, + AdaptyPurchaseResult, +} from '@/types'; +import { + PaywallEventId, + type PaywallEventView, + type ParsedPaywallEvent, +} from '@/types/paywall-events'; + +// Re-export types for convenience +export { + PaywallEventId, + type PaywallEventIdType, + type PaywallEventView, + type PaywallDidAppearEvent, + type PaywallDidDisappearEvent, + type PaywallDidPerformActionEvent, + type PaywallDidSelectProductEvent, + type PaywallDidStartPurchaseEvent, + type PaywallDidFinishPurchaseEvent, + type PaywallDidFailPurchaseEvent, + type PaywallDidStartRestoreEvent, + type PaywallDidFinishRestoreEvent, + type PaywallDidFailRestoreEvent, + type PaywallDidFailRenderingEvent, + type PaywallDidFailLoadingProductsEvent, + type PaywallDidFinishWebPaymentNavigationEvent, + type ParsedPaywallEvent, +} from '@/types/paywall-events'; + +// Parser +export function parsePaywallEvent( + input: string, + ctx?: LogContext, +): ParsedPaywallEvent | null { + let obj: Record; + try { + obj = JSON.parse(input); + } catch (error) { + throw AdaptyError.failedToDecode( + `Failed to decode event: ${(error as Error)?.message}`, + ); + } + + const eventId = obj['id'] as string | undefined; + if (!eventId?.startsWith('paywall_view_')) { + return null; + } + + const viewObj = obj['view'] as Record; + const view: PaywallEventView = { + id: viewObj['id'] as string, + placementId: viewObj['placement_id'] as string | undefined, + variationId: viewObj['variation_id'] as string | undefined, + }; + + switch (eventId) { + case PaywallEventId.DidAppear: + return { + id: eventId, + view, + }; + + case PaywallEventId.DidDisappear: + return { + id: eventId, + view, + }; + + case PaywallEventId.DidPerformAction: { + const actionObj = obj['action'] as Record; + return { + id: eventId, + view, + action: { + type: actionObj['type'] as + | 'close' + | 'system_back' + | 'open_url' + | 'custom', + value: actionObj['value'] as string | undefined, + }, + }; + } + + case PaywallEventId.DidSelectProduct: + return { + id: eventId, + view, + productId: (obj['product_id'] as string) ?? '', + }; + + case PaywallEventId.DidStartPurchase: + return { + id: eventId, + view, + product: getPaywallCoder('product', ctx)!.decode( + obj['product'], + ) as AdaptyPaywallProduct, + }; + + case PaywallEventId.DidFinishPurchase: + return { + id: eventId, + view, + purchaseResult: getPaywallCoder('purchaseResult', ctx)!.decode( + obj['purchased_result'], + ) as AdaptyPurchaseResult, + product: getPaywallCoder('product', ctx)!.decode( + obj['product'], + ) as AdaptyPaywallProduct, + }; + + case PaywallEventId.DidFailPurchase: { + const errorCoder = getPaywallCoder('error', ctx) as ErrorConverter; + const decodedError = errorCoder.decode(obj['error']); + return { + id: eventId, + view, + error: errorCoder.getError(decodedError), + product: getPaywallCoder('product', ctx)!.decode( + obj['product'], + ) as AdaptyPaywallProduct, + }; + } + + case PaywallEventId.DidStartRestore: + return { + id: eventId, + view, + }; + + case PaywallEventId.DidFinishRestore: + return { + id: eventId, + view, + profile: getPaywallCoder('profile', ctx)!.decode( + obj['profile'], + ) as AdaptyProfile, + }; + + case PaywallEventId.DidFailRestore: { + const errorCoder = getPaywallCoder('error', ctx) as ErrorConverter; + const decodedError = errorCoder.decode(obj['error']); + return { + id: eventId, + view, + error: errorCoder.getError(decodedError), + }; + } + + case PaywallEventId.DidFailRendering: { + const errorCoder = getPaywallCoder('error', ctx) as ErrorConverter; + const decodedError = errorCoder.decode(obj['error']); + return { + id: eventId, + view, + error: errorCoder.getError(decodedError), + }; + } + + case PaywallEventId.DidFailLoadingProducts: { + const errorCoder = getPaywallCoder('error', ctx) as ErrorConverter; + const decodedError = errorCoder.decode(obj['error']); + return { + id: eventId, + view, + error: errorCoder.getError(decodedError), + }; + } + + case PaywallEventId.DidFinishWebPaymentNavigation: + return { + id: eventId, + view, + product: obj['product'] + ? (getPaywallCoder('product', ctx)!.decode( + obj['product'], + ) as AdaptyPaywallProduct) + : undefined, + error: obj['error'] + ? (() => { + const errorCoder = getPaywallCoder( + 'error', + ctx, + ) as ErrorConverter; + const decodedError = errorCoder.decode(obj['error']); + return errorCoder.getError(decodedError); + })() + : undefined, + }; + + default: + return null; + } +} + +type PaywallCoderType = 'product' | 'profile' | 'purchaseResult' | 'error'; + +function getPaywallCoder( + type: PaywallCoderType, + _ctx?: LogContext, +): Converter | ErrorConverter { + switch (type) { + case 'product': + return new AdaptyPaywallProductCoder(); + case 'profile': + return new AdaptyProfileCoder(); + case 'purchaseResult': + return new AdaptyPurchaseResultCoder(); + case 'error': + return new AdaptyNativeErrorCoder(); + } +} diff --git a/src/coders/parse.ts b/src/coders/parse.ts index ca9b10de..bdfe555c 100644 --- a/src/coders/parse.ts +++ b/src/coders/parse.ts @@ -131,58 +131,6 @@ export function parseCommonEvent( } } -export function parsePaywallEvent( - input: string, - ctx?: LogContext, -): Record { - const log = ctx?.decode({ methodName: 'parsePaywallEvent' }); - log?.start({ input }); - - let obj: Record; - try { - obj = JSON.parse(input); - } catch (error) { - throw AdaptyError.failedToDecode( - `Failed to decode event: ${(error as Error)?.message}`, - ); - } - - const result: Record = {}; - - if (obj.hasOwnProperty('id')) { - result['id'] = obj['id']; - } - if (obj.hasOwnProperty('profile')) { - result['profile'] = getCoder('AdaptyProfile', ctx)?.decode(obj['profile']); - } - if (obj.hasOwnProperty('product')) { - result['product'] = getCoder('AdaptyPaywallProduct', ctx)?.decode( - obj['product'], - ); - } - if (obj.hasOwnProperty('error')) { - const errorCoder = getCoder('AdaptyError', ctx) as ErrorConverter; - const decodedError = errorCoder?.decode(obj['error']); - result['error'] = errorCoder?.getError(decodedError as any); - } - if (obj.hasOwnProperty('action')) { - result['action'] = obj['action']; - } - if (obj.hasOwnProperty('view')) { - result['view'] = obj['view']; - } - if (obj.hasOwnProperty('product_id')) { - result['product_id'] = obj['product_id']; - } - if (obj.hasOwnProperty('purchased_result')) { - result['purchased_result'] = getCoder('AdaptyPurchaseResult', ctx)?.decode( - obj['purchased_result'], - ); - } - - return result; -} - function getCoder( type: AdaptyType, ctx?: LogContext, diff --git a/src/mock/mock-request-handler.ts b/src/mock/mock-request-handler.ts index 54efa4ed..a1648012 100644 --- a/src/mock/mock-request-handler.ts +++ b/src/mock/mock-request-handler.ts @@ -1,7 +1,8 @@ import { EmitterSubscription } from 'react-native'; import { LogContext } from '@/logger'; import type { AdaptyType } from '@/coders/parse'; -import { parseCommonEvent, parsePaywallEvent } from '@/coders/parse'; +import { parseCommonEvent } from '@/coders/parse'; +import { parsePaywallEvent } from '@/coders/parse-paywall'; import { parseOnboardingEvent } from '@/coders/parse-onboarding'; import { MockStore } from './mock-store'; import type { AdaptyMockConfig } from './types'; diff --git a/src/native-request-handler/native-request-handler.ts b/src/native-request-handler/native-request-handler.ts index 499f921d..8993f754 100644 --- a/src/native-request-handler/native-request-handler.ts +++ b/src/native-request-handler/native-request-handler.ts @@ -7,11 +7,8 @@ import { import { AdaptyError } from '@/adapty-error'; import { LogContext } from '@/logger'; import { parseMethodResult } from '@/coders'; -import { - AdaptyType, - parseCommonEvent, - parsePaywallEvent, -} from '@/coders/parse'; +import { AdaptyType, parseCommonEvent } from '@/coders/parse'; +import { parsePaywallEvent } from '@/coders/parse-paywall'; import { parseOnboardingEvent } from '@/coders/parse-onboarding'; const KEY_HANDLER_NAME = 'HANDLER'; diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 8662407b..8607d1d2 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -595,6 +595,7 @@ export interface components { google_enable_pending_prepaid_plans?: boolean; google_local_access_level_allowed?: boolean; ip_address_collection_disabled?: boolean; + clear_data_on_backup?: boolean; server_cluster?: 'default' | 'eu' | 'cn'; backend_proxy_host?: string; backend_proxy_port?: number; diff --git a/src/types/inputs.ts b/src/types/inputs.ts index 72e4f13f..eb1aad22 100644 --- a/src/types/inputs.ts +++ b/src/types/inputs.ts @@ -166,6 +166,11 @@ export interface ActivateParamsInput { */ idfaCollectionDisabled?: boolean; appAccountToken?: string; + /** + * Controls whether the SDK will create a new profile when the app is restored from an iCloud backup + * @defaultValue `false` + */ + clearDataOnBackup?: boolean; }; android?: { /** diff --git a/src/types/paywall-events.ts b/src/types/paywall-events.ts new file mode 100644 index 00000000..5790c2d5 --- /dev/null +++ b/src/types/paywall-events.ts @@ -0,0 +1,125 @@ +import { AdaptyError } from '@/adapty-error'; +import type { + AdaptyPaywallProduct, + AdaptyProfile, + AdaptyPurchaseResult, +} from '@/types'; + +// Paywall Event IDs +export const PaywallEventId = { + DidAppear: 'paywall_view_did_appear', + DidDisappear: 'paywall_view_did_disappear', + DidPerformAction: 'paywall_view_did_perform_action', + DidSelectProduct: 'paywall_view_did_select_product', + DidStartPurchase: 'paywall_view_did_start_purchase', + DidFinishPurchase: 'paywall_view_did_finish_purchase', + DidFailPurchase: 'paywall_view_did_fail_purchase', + DidStartRestore: 'paywall_view_did_start_restore', + DidFinishRestore: 'paywall_view_did_finish_restore', + DidFailRestore: 'paywall_view_did_fail_restore', + DidFailRendering: 'paywall_view_did_fail_rendering', + DidFailLoadingProducts: 'paywall_view_did_fail_loading_products', + DidFinishWebPaymentNavigation: + 'paywall_view_did_finish_web_payment_navigation', +} as const; + +export type PaywallEventIdType = + (typeof PaywallEventId)[keyof typeof PaywallEventId]; + +// Event View +export interface PaywallEventView { + id: string; + placementId?: string; + variationId?: string; +} + +// Base Event +interface BasePaywallEvent { + id: PaywallEventIdType; + view: PaywallEventView; +} + +// Event Types +export interface PaywallDidAppearEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidAppear; +} + +export interface PaywallDidDisappearEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidDisappear; +} + +export interface PaywallDidPerformActionEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidPerformAction; + action: { + type: 'close' | 'system_back' | 'open_url' | 'custom'; + value?: string; // for open_url and custom + }; +} + +export interface PaywallDidSelectProductEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidSelectProduct; + productId: string; +} + +export interface PaywallDidStartPurchaseEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidStartPurchase; + product: AdaptyPaywallProduct; +} + +export interface PaywallDidFinishPurchaseEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidFinishPurchase; + purchaseResult: AdaptyPurchaseResult; + product: AdaptyPaywallProduct; +} + +export interface PaywallDidFailPurchaseEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidFailPurchase; + error: AdaptyError; + product: AdaptyPaywallProduct; +} + +export interface PaywallDidStartRestoreEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidStartRestore; +} + +export interface PaywallDidFinishRestoreEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidFinishRestore; + profile: AdaptyProfile; +} + +export interface PaywallDidFailRestoreEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidFailRestore; + error: AdaptyError; +} + +export interface PaywallDidFailRenderingEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidFailRendering; + error: AdaptyError; +} + +export interface PaywallDidFailLoadingProductsEvent extends BasePaywallEvent { + id: typeof PaywallEventId.DidFailLoadingProducts; + error: AdaptyError; +} + +export interface PaywallDidFinishWebPaymentNavigationEvent + extends BasePaywallEvent { + id: typeof PaywallEventId.DidFinishWebPaymentNavigation; + product?: AdaptyPaywallProduct; + error?: AdaptyError; +} + +export type ParsedPaywallEvent = + | PaywallDidAppearEvent + | PaywallDidDisappearEvent + | PaywallDidPerformActionEvent + | PaywallDidSelectProductEvent + | PaywallDidStartPurchaseEvent + | PaywallDidFinishPurchaseEvent + | PaywallDidFailPurchaseEvent + | PaywallDidStartRestoreEvent + | PaywallDidFinishRestoreEvent + | PaywallDidFailRestoreEvent + | PaywallDidFailRenderingEvent + | PaywallDidFailLoadingProductsEvent + | PaywallDidFinishWebPaymentNavigationEvent; diff --git a/src/ui/AdaptyPaywallView.tsx b/src/ui/AdaptyPaywallView.tsx index 9bb6f057..258cd524 100644 --- a/src/ui/AdaptyPaywallView.tsx +++ b/src/ui/AdaptyPaywallView.tsx @@ -18,13 +18,11 @@ export type Props = ViewProps & { paywall: AdaptyPaywall; params?: CreatePaywallViewParamsInput; onCloseButtonPress?: EventHandlers['onCloseButtonPress']; - onAndroidSystemBack?: EventHandlers['onAndroidSystemBack']; onProductSelected?: EventHandlers['onProductSelected']; onPurchaseStarted?: EventHandlers['onPurchaseStarted']; onPurchaseCompleted?: EventHandlers['onPurchaseCompleted']; onPurchaseFailed?: EventHandlers['onPurchaseFailed']; onRestoreStarted?: EventHandlers['onRestoreStarted']; - onPaywallClosed?: EventHandlers['onPaywallClosed']; onPaywallShown?: EventHandlers['onPaywallShown']; onWebPaymentNavigationFinished?: EventHandlers['onWebPaymentNavigationFinished']; onRestoreCompleted?: EventHandlers['onRestoreCompleted']; @@ -43,13 +41,11 @@ const AdaptyPaywallViewComponent: React.FC = ({ paywall, params, onCloseButtonPress, - onAndroidSystemBack, onProductSelected, onPurchaseStarted, onPurchaseCompleted, onPurchaseFailed, onRestoreStarted, - onPaywallClosed, onPaywallShown, onWebPaymentNavigationFinished, onRestoreCompleted, @@ -78,13 +74,11 @@ const AdaptyPaywallViewComponent: React.FC = ({ const handlers: Partial = {}; if (onCloseButtonPress) handlers.onCloseButtonPress = onCloseButtonPress; - if (onAndroidSystemBack) handlers.onAndroidSystemBack = onAndroidSystemBack; if (onProductSelected) handlers.onProductSelected = onProductSelected; if (onPurchaseStarted) handlers.onPurchaseStarted = onPurchaseStarted; if (onPurchaseCompleted) handlers.onPurchaseCompleted = onPurchaseCompleted; if (onPurchaseFailed) handlers.onPurchaseFailed = onPurchaseFailed; if (onRestoreStarted) handlers.onRestoreStarted = onRestoreStarted; - if (onPaywallClosed) handlers.onPaywallClosed = onPaywallClosed; if (onPaywallShown) handlers.onPaywallShown = onPaywallShown; if (onWebPaymentNavigationFinished) handlers.onWebPaymentNavigationFinished = onWebPaymentNavigationFinished; @@ -99,13 +93,11 @@ const AdaptyPaywallViewComponent: React.FC = ({ return handlers; }, [ onCloseButtonPress, - onAndroidSystemBack, onProductSelected, onPurchaseStarted, onPurchaseCompleted, onPurchaseFailed, onRestoreStarted, - onPaywallClosed, onPaywallShown, onWebPaymentNavigationFinished, onRestoreCompleted, diff --git a/src/ui/create-paywall-event-handlers.test.ts b/src/ui/create-paywall-event-handlers.test.ts index 77844a15..c2b6846f 100644 --- a/src/ui/create-paywall-event-handlers.test.ts +++ b/src/ui/create-paywall-event-handlers.test.ts @@ -71,9 +71,9 @@ describe('createPaywallEventHandlers', () => { createPaywallEventHandlers({}, 'test-id'); - // Should register 5 default handlers: - // onCloseButtonPress, onAndroidSystemBack, onRestoreCompleted, onPurchaseCompleted, onUrlPress - expect(addListener).toHaveBeenCalledTimes(5); + // Should register 6 default handlers: + // onCloseButtonPress, onAndroidSystemBack, onRestoreCompleted, onRenderingFailed, onPurchaseCompleted, onUrlPress + expect(addListener).toHaveBeenCalledTimes(6); const calls = (addListener as jest.Mock).mock.calls; const registeredEvents = calls.map(call => call[0]); @@ -81,6 +81,7 @@ describe('createPaywallEventHandlers', () => { expect(registeredEvents).toContain('onCloseButtonPress'); expect(registeredEvents).toContain('onAndroidSystemBack'); expect(registeredEvents).toContain('onRestoreCompleted'); + expect(registeredEvents).toContain('onRenderingFailed'); expect(registeredEvents).toContain('onPurchaseCompleted'); expect(registeredEvents).toContain('onUrlPress'); }); @@ -104,8 +105,8 @@ describe('createPaywallEventHandlers', () => { 'test-id', ); - // Should register 5 defaults + 2 custom = 7 handlers - expect(addListener).toHaveBeenCalledTimes(7); + // Should register 6 defaults + 2 custom = 8 handlers + expect(addListener).toHaveBeenCalledTimes(8); const calls = (addListener as jest.Mock).mock.calls; const productSelectedCall = calls.find( @@ -181,8 +182,8 @@ describe('createPaywallEventHandlers', () => { expect(closeCall[1]).toBe(customCloseHandler); expect(restoreCall[1]).toBe(customRestoreHandler); - // Should still have only 5 handlers (not 7), because custom ones override defaults - expect(addListener).toHaveBeenCalledTimes(5); + // Should still have only 6 handlers (not 8), because custom ones override defaults + expect(addListener).toHaveBeenCalledTimes(6); }); it('creates default onRequestClose when not provided', () => { diff --git a/src/ui/onboarding-view-controller.ts b/src/ui/onboarding-view-controller.ts index b0415cad..65c67711 100644 --- a/src/ui/onboarding-view-controller.ts +++ b/src/ui/onboarding-view-controller.ts @@ -54,7 +54,7 @@ export class OnboardingViewController { const data: Req['AdaptyUICreateOnboardingView.Request'] = { method: methodKey, onboarding: coder.encode(onboarding), - ...(encodedParams as any), + ...encodedParams, }; const body = JSON.stringify(data); diff --git a/src/ui/types.ts b/src/ui/types.ts index ae8b60e3..8b097807 100644 --- a/src/ui/types.ts +++ b/src/ui/types.ts @@ -326,6 +326,7 @@ export const DEFAULT_EVENT_HANDLERS: Partial = { onCloseButtonPress: () => true, onAndroidSystemBack: () => true, onRestoreCompleted: () => true, + onRenderingFailed: () => true, onPurchaseCompleted: (purchaseResult: AdaptyPurchaseResult) => purchaseResult.type !== 'user_cancelled', onUrlPress: url => { diff --git a/src/ui/view-controller.test.ts b/src/ui/view-controller.test.ts index b607ae82..b7649717 100644 --- a/src/ui/view-controller.test.ts +++ b/src/ui/view-controller.test.ts @@ -21,6 +21,7 @@ jest.mock('./view-emitter', () => { return { ViewEmitter: jest.fn().mockImplementation(() => ({ addListener: jest.fn(), + addInternalListener: jest.fn(), removeAllListeners: jest.fn(), })), }; @@ -127,7 +128,7 @@ describe('ViewController', () => { }); describe('dismiss', () => { - it('calls bridge and unsubscribes listeners', async () => { + it('calls bridge and registers internal cleanup handler', async () => { const { AdaptyPaywallCoder } = jest.requireMock( '@/coders/adapty-paywall', ); @@ -140,15 +141,23 @@ describe('ViewController', () => { .mockResolvedValueOnce(undefined); // dismiss const { ViewEmitter } = jest.requireMock('./view-emitter'); + const addInternalListenerMock = jest.fn(); const removeAllListenersMock = jest.fn(); (ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({ addListener: jest.fn(), + addInternalListener: addInternalListenerMock, removeAllListeners: removeAllListenersMock, })); const view = await ViewController.create(paywall, {} as any); view.setEventHandlers({ onCloseButtonPress: () => true }); + // Verify internal handler was registered for cleanup + expect(addInternalListenerMock).toHaveBeenCalledWith( + 'onPaywallClosed', + expect.any(Function), + ); + await view.dismiss(); expect($bridge.request).toHaveBeenLastCalledWith( @@ -157,7 +166,10 @@ describe('ViewController', () => { 'Void', expect.any(Object), ); - expect(removeAllListenersMock).toHaveBeenCalled(); + + // Cleanup now happens via internal handler, not directly in dismiss() + // So removeAllListeners should NOT be called immediately after dismiss + expect(removeAllListenersMock).not.toHaveBeenCalled(); }); it('throws if id is null', async () => { @@ -181,6 +193,7 @@ describe('ViewController', () => { const addListener = jest.fn(); (ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({ addListener, + addInternalListener: jest.fn(), removeAllListeners: jest.fn(), })); @@ -229,6 +242,7 @@ describe('ViewController', () => { // Mock ViewEmitter instance BEFORE create (ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({ addListener, + addInternalListener: jest.fn(), removeAllListeners, })); @@ -292,6 +306,7 @@ describe('ViewController', () => { }); (ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({ addListener, + addInternalListener: jest.fn(), removeAllListeners: jest.fn(), })); @@ -347,6 +362,7 @@ describe('ViewController', () => { }); (ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({ addListener, + addInternalListener: jest.fn(), removeAllListeners: jest.fn(), })); @@ -357,15 +373,17 @@ describe('ViewController', () => { const view = await ViewController.create(paywall, {} as any); // After create, default handlers should be registered - // (onCloseButtonPress, onAndroidSystemBack, onRestoreCompleted, onPurchaseCompleted, onUrlPress) + // (onCloseButtonPress, onAndroidSystemBack, onRestoreCompleted, onRenderingFailed, onPurchaseCompleted, onUrlPress) expect(handlers.has('onCloseButtonPress')).toBe(true); expect(handlers.has('onAndroidSystemBack')).toBe(true); expect(handlers.has('onRestoreCompleted')).toBe(true); + expect(handlers.has('onRenderingFailed')).toBe(true); expect(handlers.has('onPurchaseCompleted')).toBe(true); expect(handlers.has('onUrlPress')).toBe(true); const defaultOnAndroidSystemBack = handlers.get('onAndroidSystemBack'); const defaultOnRestoreCompleted = handlers.get('onRestoreCompleted'); + const defaultOnRenderingFailed = handlers.get('onRenderingFailed'); const defaultOnUrlPress = handlers.get('onUrlPress'); // Now override only onCloseButtonPress @@ -382,6 +400,7 @@ describe('ViewController', () => { expect(handlers.get('onRestoreCompleted')).toBe( defaultOnRestoreCompleted, ); + expect(handlers.get('onRenderingFailed')).toBe(defaultOnRenderingFailed); expect(handlers.get('onUrlPress')).toBe(defaultOnUrlPress); expect(handlers.has('onPurchaseCompleted')).toBe(true); }); @@ -398,6 +417,7 @@ describe('ViewController', () => { const addListener = jest.fn(); (ViewEmitter as unknown as jest.Mock).mockImplementation(() => ({ addListener, + addInternalListener: jest.fn(), removeAllListeners: jest.fn(), })); diff --git a/src/ui/view-controller.ts b/src/ui/view-controller.ts index 62cc44f5..90097ea5 100644 --- a/src/ui/view-controller.ts +++ b/src/ui/view-controller.ts @@ -72,9 +72,18 @@ export class ViewController { ); view.id = result.id; + view.viewEmitter = new ViewEmitter(result.id); view.setEventHandlers(DEFAULT_EVENT_HANDLERS); + // Register internal handler for cleanup + view.viewEmitter.addInternalListener('onPaywallClosed', () => { + // Called AFTER client's onPaywallClosed handler + if (view.viewEmitter) { + view.viewEmitter.removeAllListeners(); + } + }); + return view; } @@ -184,10 +193,6 @@ export class ViewController { } satisfies Req['AdaptyUIDismissPaywallView.Request']); await this.handle(methodKey, body, 'Void', ctx, log); - - if (this.viewEmitter) { - this.viewEmitter.removeAllListeners(); - } } /** @@ -259,6 +264,7 @@ export class ViewController { * - `onCloseButtonPress` - closes paywall (returns `true`) * - `onAndroidSystemBack` - closes paywall (returns `true`) * - `onRestoreCompleted` - closes paywall (returns `true`) + * - `onRenderingFailed` - closes paywall (returns `true`) * - `onPurchaseCompleted` - closes paywall on success (returns `purchaseResult.type !== 'user_cancelled'`) * - `onUrlPress` - opens URL and keeps paywall open (returns `false`) * diff --git a/src/ui/view-emitter.ts b/src/ui/view-emitter.ts index a2811af4..ac874b7b 100644 --- a/src/ui/view-emitter.ts +++ b/src/ui/view-emitter.ts @@ -1,6 +1,12 @@ import type { EventHandlers } from './types'; import { $bridge } from '@/bridge'; import { EmitterSubscription } from 'react-native'; +import { + ParsedPaywallEvent, + PaywallEventId, + PaywallEventIdType, +} from '@/types/paywall-events'; +import { LogContext } from '@/logger'; type EventName = keyof EventHandlers; @@ -30,10 +36,15 @@ export class ViewEmitter { EventName, { handler: EventHandlers[EventName]; - config: (typeof HANDLER_TO_EVENT_CONFIG)[EventName]; onRequestClose: () => Promise; } > = new Map(); + private internalHandlers: Map< + EventName, + { + handler: (event: ParsedPaywallEvent) => void; + } + > = new Map(); constructor(viewId: string) { this.viewId = viewId; @@ -44,206 +55,263 @@ export class ViewEmitter { callback: EventHandlers[EventName], onRequestClose: () => Promise, ): EmitterSubscription { - const viewId = this.viewId; - const config = HANDLER_TO_EVENT_CONFIG[event]; + const nativeEvent = HANDLER_TO_NATIVE_EVENT[event]; - if (!config) { - throw new Error(`No event config found for handler: ${event}`); + if (!nativeEvent) { + throw new Error(`No native event mapping found for handler: ${event}`); } // Replace existing handler for this event type this.handlers.set(event, { handler: callback, - config, onRequestClose, }); - if (!this.eventListeners.has(config.nativeEvent)) { - const handlers = this.handlers; // Capture the reference + // If no subscription to native event exists - create one + if (!this.eventListeners.has(nativeEvent)) { const subscription = $bridge.addEventListener( - config.nativeEvent, - function (arg) { - const eventViewId = this.rawValue['view']?.['id'] ?? null; - if (viewId !== eventViewId) { - return; - } + nativeEvent, + this.createEventHandler(nativeEvent), + ); + this.eventListeners.set(nativeEvent, subscription); + } - // Get all possible handler names for this native event - const possibleHandlers = - NATIVE_EVENT_TO_HANDLERS[config.nativeEvent] || []; - - for (const handlerName of possibleHandlers) { - const handlerData = handlers.get(handlerName); - if (!handlerData) { - continue; // Handler not registered for this view - } - - const { - handler, - config: handlerConfig, - onRequestClose, - } = handlerData; - - if ( - handlerConfig.propertyMap && - (arg as any)['action']?.type !== - handlerConfig.propertyMap['action'] - ) { - continue; - } - - const callbackArgs = extractCallbackArgs( - handlerName, - arg as Record, - ); - const cb = handler as (...args: typeof callbackArgs) => boolean; - const shouldClose = cb.apply(null, callbackArgs); - - if (shouldClose) { - onRequestClose().catch(() => { - // Ignore errors from onRequestClose to avoid breaking event handling - }); - } - } - }, + return this.eventListeners.get(nativeEvent)!; + } + + /** + * Adds an internal event handler. + * Internal handlers: + * - Are called AFTER client handlers + * - Do NOT return boolean (don't affect auto-dismiss) + * - Are used for internal SDK logic (e.g., cleanup) + * @internal + */ + public addInternalListener( + event: EventName, + callback: (event: ParsedPaywallEvent) => void, + ): void { + const nativeEvent = HANDLER_TO_NATIVE_EVENT[event]; + + if (!nativeEvent) { + throw new Error(`No native event mapping found for handler: ${event}`); + } + + // Replace existing internal handler for this event + this.internalHandlers.set(event, { + handler: callback, + }); + + // If no subscription to native event exists - create one + if (!this.eventListeners.has(nativeEvent)) { + const subscription = $bridge.addEventListener( + nativeEvent, + this.createEventHandler(nativeEvent), ); - this.eventListeners.set(config.nativeEvent, subscription); + this.eventListeners.set(nativeEvent, subscription); } + } + + private createEventHandler(nativeEvent: PaywallEventIdType) { + return (parsedEvent: ParsedPaywallEvent | null) => { + if (!parsedEvent) { + return; + } + + const eventViewId = parsedEvent.view.id; + if (eventViewId !== this.viewId) { + return; // Event for different view + } + + const ctx = new LogContext(); + const log = ctx.event({ methodName: nativeEvent }); + log.start({ viewId: eventViewId, eventId: parsedEvent.id }); + + // Resolve handler name from event + const resolver = NATIVE_EVENT_RESOLVER[nativeEvent]; + if (!resolver) { + log.failed({ reason: 'no_resolver', nativeEvent }); + return; + } + + const resolvedHandler = resolver(parsedEvent); + if (!resolvedHandler) { + // Event doesn't match any handler (e.g., unknown action type) + return; + } + + // TypeScript doesn't narrow the type after the null check, so we assert it + const handlerName = resolvedHandler as EventName; + + let hasError = false; + + // 1. Client handlers + const handlerData = this.handlers.get(handlerName); + if (handlerData) { + const { handler, onRequestClose } = handlerData; + const callbackArgs = extractCallbackArgs(handlerName, parsedEvent); + const callback = handler as ( + ...args: ExtractedArgs + ) => boolean; + + try { + const shouldClose = callback(...callbackArgs); + + if (shouldClose) { + onRequestClose().catch(error => { + log.failed({ + error, + handlerName, + viewId: eventViewId, + eventId: parsedEvent.id, + reason: 'on_request_close_failed', + }); + }); + } + } catch (error) { + hasError = true; + log.failed({ + error, + handlerName, + viewId: eventViewId, + eventId: parsedEvent.id, + reason: 'handler_error', + }); + } + } + + // 2. Internal handlers + const internalHandlerData = this.internalHandlers.get(handlerName); + if (internalHandlerData) { + try { + internalHandlerData.handler(parsedEvent); + } catch (error) { + hasError = true; + log.failed({ + error, + handlerName: `${handlerName} (internal)`, + viewId: eventViewId, + eventId: parsedEvent.id, + reason: 'internal_handler_failed', + }); + } + } - return this.eventListeners.get(config.nativeEvent)!; + if (!hasError) { + log.success({ viewId: eventViewId, eventId: parsedEvent.id }); + } + }; } public removeAllListeners() { this.eventListeners.forEach(subscription => subscription.remove()); this.eventListeners.clear(); this.handlers.clear(); + this.internalHandlers.clear(); } } -type UiEventMapping = { - [nativeEventId: string]: { - handlerName: keyof EventHandlers; - propertyMap?: { - [key: string]: string; +/** + * Resolves native event to handler name based on event data + */ +const NATIVE_EVENT_RESOLVER: Record< + PaywallEventIdType, + (event: ParsedPaywallEvent) => EventName | null +> = { + paywall_view_did_perform_action: event => { + if (event.id !== PaywallEventId.DidPerformAction) return null; + + const actionMap: Record = { + close: 'onCloseButtonPress', + system_back: 'onAndroidSystemBack', + open_url: 'onUrlPress', + custom: 'onCustomAction', }; - }[]; + + return actionMap[event.action.type] || null; + }, + paywall_view_did_appear: () => 'onPaywallShown', + paywall_view_did_disappear: () => 'onPaywallClosed', + paywall_view_did_select_product: () => 'onProductSelected', + paywall_view_did_start_purchase: () => 'onPurchaseStarted', + paywall_view_did_finish_purchase: () => 'onPurchaseCompleted', + paywall_view_did_fail_purchase: () => 'onPurchaseFailed', + paywall_view_did_start_restore: () => 'onRestoreStarted', + paywall_view_did_finish_restore: () => 'onRestoreCompleted', + paywall_view_did_fail_restore: () => 'onRestoreFailed', + paywall_view_did_fail_rendering: () => 'onRenderingFailed', + paywall_view_did_fail_loading_products: () => 'onLoadingProductsFailed', + paywall_view_did_finish_web_payment_navigation: () => + 'onWebPaymentNavigationFinished', }; -const UI_EVENT_MAPPINGS: UiEventMapping = { - paywall_view_did_perform_action: [ - { - handlerName: 'onCloseButtonPress', - propertyMap: { - action: 'close', - }, - }, - { - handlerName: 'onAndroidSystemBack', - propertyMap: { - action: 'system_back', - }, - }, - { - handlerName: 'onUrlPress', - propertyMap: { - action: 'open_url', - }, - }, - { - handlerName: 'onCustomAction', - propertyMap: { - action: 'custom', - }, - }, - ], - paywall_view_did_select_product: [{ handlerName: 'onProductSelected' }], - paywall_view_did_start_purchase: [{ handlerName: 'onPurchaseStarted' }], - paywall_view_did_finish_purchase: [{ handlerName: 'onPurchaseCompleted' }], - paywall_view_did_fail_purchase: [{ handlerName: 'onPurchaseFailed' }], - paywall_view_did_start_restore: [{ handlerName: 'onRestoreStarted' }], - paywall_view_did_appear: [{ handlerName: 'onPaywallShown' }], - paywall_view_did_disappear: [{ handlerName: 'onPaywallClosed' }], - paywall_view_did_finish_web_payment_navigation: [ - { handlerName: 'onWebPaymentNavigationFinished' }, - ], - paywall_view_did_finish_restore: [{ handlerName: 'onRestoreCompleted' }], - paywall_view_did_fail_restore: [{ handlerName: 'onRestoreFailed' }], - paywall_view_did_fail_rendering: [{ handlerName: 'onRenderingFailed' }], - paywall_view_did_fail_loading_products: [ - { handlerName: 'onLoadingProductsFailed' }, - ], +/** + * Maps handler name to native event name + * Used in addListener/addInternalListener to subscribe to correct native event + */ +const HANDLER_TO_NATIVE_EVENT: Record = { + onCloseButtonPress: 'paywall_view_did_perform_action', + onAndroidSystemBack: 'paywall_view_did_perform_action', + onUrlPress: 'paywall_view_did_perform_action', + onCustomAction: 'paywall_view_did_perform_action', + onPaywallShown: 'paywall_view_did_appear', + onPaywallClosed: 'paywall_view_did_disappear', + onProductSelected: 'paywall_view_did_select_product', + onPurchaseStarted: 'paywall_view_did_start_purchase', + onPurchaseCompleted: 'paywall_view_did_finish_purchase', + onPurchaseFailed: 'paywall_view_did_fail_purchase', + onRestoreStarted: 'paywall_view_did_start_restore', + onRestoreCompleted: 'paywall_view_did_finish_restore', + onRestoreFailed: 'paywall_view_did_fail_restore', + onRenderingFailed: 'paywall_view_did_fail_rendering', + onLoadingProductsFailed: 'paywall_view_did_fail_loading_products', + onWebPaymentNavigationFinished: + 'paywall_view_did_finish_web_payment_navigation', }; -const HANDLER_TO_EVENT_CONFIG: Record< - EventName, - { - nativeEvent: string; - propertyMap?: { [key: string]: string }; - handlerName: EventName; - } -> = Object.entries(UI_EVENT_MAPPINGS).reduce( - (acc, [nativeEvent, mappings]) => { - mappings.forEach(({ handlerName, propertyMap }) => { - acc[handlerName] = { - nativeEvent, - propertyMap, - handlerName, - }; - }); - return acc; - }, - {} as Record< - EventName, - { - nativeEvent: string; - propertyMap?: { [key: string]: string }; - handlerName: EventName; - } - >, -); - -// Reverse mapping: nativeEvent -> EventName[] -const NATIVE_EVENT_TO_HANDLERS: Record = Object.entries( - HANDLER_TO_EVENT_CONFIG, -).reduce( - (acc, [handlerName, config]) => { - if (!acc[config.nativeEvent]) { - acc[config.nativeEvent] = []; - } - const handlers = acc[config.nativeEvent]; - if (handlers) { - handlers.push(handlerName as EventName); - } - return acc; - }, - {} as Record, -); - -function extractCallbackArgs( - handlerName: EventName, - eventArg: Record, -) { - switch (handlerName) { - case 'onProductSelected': - return [eventArg['product_id']]; - case 'onPurchaseStarted': - return [eventArg['product']]; - case 'onPurchaseCompleted': - return [eventArg['purchased_result'], eventArg['product']]; - case 'onPurchaseFailed': - return [eventArg['error'], eventArg['product']]; - case 'onRestoreCompleted': - return [eventArg['profile']]; - case 'onRestoreFailed': - case 'onRenderingFailed': - case 'onLoadingProductsFailed': - return [eventArg['error']]; - case 'onCustomAction': - case 'onUrlPress': - return [eventArg['action'].value]; - case 'onWebPaymentNavigationFinished': - return [eventArg['product'], eventArg['error']]; - default: - return []; +type ExtractedArgs = Parameters< + EventHandlers[T] +>; + +function extractCallbackArgs( + handlerName: T, + event: ParsedPaywallEvent, +): ExtractedArgs { + switch (event.id) { + case PaywallEventId.DidSelectProduct: + return [event.productId] as ExtractedArgs; + + case PaywallEventId.DidStartPurchase: + return [event.product] as ExtractedArgs; + + case PaywallEventId.DidFinishPurchase: + return [event.purchaseResult, event.product] as ExtractedArgs; + + case PaywallEventId.DidFailPurchase: + return [event.error, event.product] as ExtractedArgs; + + case PaywallEventId.DidFinishRestore: + return [event.profile] as ExtractedArgs; + + case PaywallEventId.DidFailRestore: + case PaywallEventId.DidFailRendering: + case PaywallEventId.DidFailLoadingProducts: + return [event.error] as ExtractedArgs; + + case PaywallEventId.DidPerformAction: + // For DidPerformAction, different handlers need different arguments + if (handlerName === 'onUrlPress' || handlerName === 'onCustomAction') { + return [event.action.value ?? ''] as ExtractedArgs; + } + // onCloseButtonPress, onAndroidSystemBack don't take arguments + return [] as ExtractedArgs; + + case PaywallEventId.DidFinishWebPaymentNavigation: + return [event.product, event.error] as unknown as ExtractedArgs; + + case PaywallEventId.DidAppear: + case PaywallEventId.DidDisappear: + case PaywallEventId.DidStartRestore: + return [] as ExtractedArgs; } }