├── .eslintignore ├── sdk └── src │ ├── internal │ ├── logger │ │ ├── index.ts │ │ ├── types.ts │ │ └── logger.ts │ ├── user │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── UserIdGenerator.ts │ │ ├── UserServiceDecorator.ts │ │ ├── types.ts │ │ ├── UserDataStorage.ts │ │ ├── IdentityService.ts │ │ ├── UserService.ts │ │ └── UserController.ts │ ├── common │ │ ├── index.ts │ │ ├── constants.ts │ │ ├── types.ts │ │ └── LocalStorage.ts │ ├── index.ts │ ├── purchases │ │ ├── index.ts │ │ ├── types.ts │ │ ├── PurchasesController.ts │ │ └── PurchaseService.ts │ ├── userProperties │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── types.ts │ │ ├── UserPropertiesStorage.ts │ │ └── UserPropertiesService.ts │ ├── entitlements │ │ ├── index.ts │ │ ├── types.ts │ │ ├── EntitlementsService.ts │ │ └── EntitlementsController.ts │ ├── utils │ │ ├── dateUtils.ts │ │ ├── propertyUtils.ts │ │ ├── objectUtils.ts │ │ └── DelayedWorker.ts │ ├── network │ │ ├── index.ts │ │ ├── constants.ts │ │ ├── utils.ts │ │ ├── RetryPolicy.ts │ │ ├── RetryDelayCalculator.ts │ │ ├── NetworkClient.ts │ │ ├── HeaderBuilder.ts │ │ ├── types.ts │ │ ├── RequestConfigurator.ts │ │ └── ApiInteractor.ts │ ├── types.ts │ ├── di │ │ ├── MiscAssembly.ts │ │ ├── StorageAssembly.ts │ │ ├── NetworkAssembly.ts │ │ ├── types.ts │ │ ├── ServicesAssembly.ts │ │ ├── ControllersAssembly.ts │ │ └── DependenciesAssembly.ts │ ├── InternalConfig.ts │ └── QonversionInternal.ts │ ├── dto │ ├── User.ts │ ├── Purchase.ts │ ├── Environment.ts │ ├── UserProperty.ts │ ├── Entitlement.ts │ ├── LogLevel.ts │ ├── UserPropertyKey.ts │ └── UserProperties.ts │ ├── __tests__ │ ├── utils.ts │ ├── Qonversion.test.ts │ ├── internal │ │ ├── user │ │ │ ├── UserIdGenerator.test.ts │ │ │ ├── UserDataStorage.test.ts │ │ │ ├── UserServiceDecorator.test.ts │ │ │ ├── IdentityService.test.ts │ │ │ └── UserService.test.ts │ │ ├── network │ │ │ ├── RetryDelayCalculator.test.ts │ │ │ ├── HeaderBuilder.test.ts │ │ │ └── NetworkClient.test.ts │ │ ├── utils │ │ │ ├── objectUtils.test.ts │ │ │ └── DelayedWorker.test.ts │ │ ├── purchases │ │ │ ├── PurchasesController.test.ts │ │ │ └── PurchasesService.test.ts │ │ ├── logger │ │ │ └── logger.test.ts │ │ ├── InternalConfig.test.ts │ │ ├── entitlements │ │ │ ├── EntitlementsService.test.ts │ │ │ └── EntitlementsController.test.ts │ │ ├── common │ │ │ └── LocalStorage.test.ts │ │ └── userProperties │ │ │ └── UserPropertiesStorage.test.ts │ └── QonversionConfigBuilder.test.ts │ ├── exception │ ├── QonversionErrorCode.ts │ └── QonversionError.ts │ ├── index.ts │ ├── __integrationTests__ │ ├── constants.ts │ ├── utils.ts │ ├── aegis │ │ ├── EntitlementsService.test.ts │ │ ├── UserService.test.ts │ │ └── IdentityService.test.ts │ ├── apiV3 │ │ ├── EntitlementsService.test.ts │ │ ├── UserService.test.ts │ │ └── IdentityService.test.ts │ └── apiV3Utils.ts │ ├── Qonversion.ts │ └── QonversionConfigBuilder.ts ├── babel.config.js ├── .github └── workflows │ ├── stale_issues.yml │ ├── release_pull_requests.yml │ ├── prerelease_github.yml │ ├── publish.yml │ ├── integration_tests.yml │ └── pr-checks.yml ├── .gitignore ├── fastlane ├── report.xml ├── README.md └── Fastfile ├── tsconfig.json ├── .eslintrc.cjs ├── LICENSE ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | sdk/build 2 | sdk/src/__tests__ 3 | babel.config.js -------------------------------------------------------------------------------- /sdk/src/internal/logger/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './logger'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /sdk/src/internal/user/constants.ts: -------------------------------------------------------------------------------- 1 | export const USER_ID_PREFIX = "QON"; 2 | export const USER_ID_SEPARATOR = "_"; 3 | -------------------------------------------------------------------------------- /sdk/src/internal/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './constants'; 3 | export * from './LocalStorage'; 4 | -------------------------------------------------------------------------------- /sdk/src/internal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './InternalConfig'; 3 | export * from './QonversionInternal'; 4 | -------------------------------------------------------------------------------- /sdk/src/internal/purchases/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './PurchaseService'; 3 | export * from './PurchasesController'; 4 | -------------------------------------------------------------------------------- /sdk/src/internal/userProperties/constants.ts: -------------------------------------------------------------------------------- 1 | export const SENDING_DELAY_MS = 5000; 2 | export const KEY_REGEX = '(?=.*[a-zA-Z])^[-a-zA-Z0-9_.:]+$'; 3 | -------------------------------------------------------------------------------- /sdk/src/internal/entitlements/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './EntitlementsService'; 3 | export * from './EntitlementsController'; 4 | -------------------------------------------------------------------------------- /sdk/src/dto/User.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: string, 3 | identityId?: string | null, 4 | created: number, 5 | environment: 'prod' | 'sandbox', 6 | }; 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /sdk/src/internal/userProperties/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './UserPropertiesController'; 3 | export * from './UserPropertiesService'; 4 | export * from './UserPropertiesStorage'; 5 | -------------------------------------------------------------------------------- /.github/workflows/stale_issues.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues 2 | on: 3 | schedule: 4 | - cron: '30 1 * * *' 5 | 6 | jobs: 7 | stale_issues: 8 | uses: qonversion/shared-sdk-workflows/.github/workflows/stale_issues.yml@main -------------------------------------------------------------------------------- /sdk/src/internal/logger/types.ts: -------------------------------------------------------------------------------- 1 | export type LogMethod = (message: string, ...objects: unknown[]) => void; 2 | 3 | export type Logger = { 4 | verbose: LogMethod; 5 | 6 | info: LogMethod; 7 | 8 | warn: LogMethod; 9 | 10 | error: LogMethod; 11 | }; 12 | -------------------------------------------------------------------------------- /.github/workflows/release_pull_requests.yml: -------------------------------------------------------------------------------- 1 | name: Release pull requests from dev by tag 2 | on: 3 | push: 4 | tags: 5 | - prerelease/* 6 | 7 | jobs: 8 | handle_prerelease: 9 | uses: qonversion/shared-sdk-workflows/.github/workflows/prerelease_handling.yml@main 10 | -------------------------------------------------------------------------------- /sdk/src/internal/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './UserDataStorage'; 3 | export * from './UserIdGenerator'; 4 | export * from './UserService'; 5 | export * from './UserServiceDecorator'; 6 | export * from './IdentityService'; 7 | export * from './UserController'; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | # OSX 4 | # 5 | .DS_Store 6 | 7 | # node.js 8 | # 9 | node_modules/ 10 | npm-debug.log 11 | yarn-error.log 12 | 13 | build/ 14 | 15 | # BUCK 16 | buck-out/ 17 | \.buckd/ 18 | *.keystore 19 | 20 | # vscode 21 | .vscode 22 | 23 | # Environment variables 24 | .env -------------------------------------------------------------------------------- /sdk/src/internal/utils/dateUtils.ts: -------------------------------------------------------------------------------- 1 | export const DAYS_IN_MONTH = 30; 2 | export const DAYS_IN_WEEK = 7; 3 | export const MIN_IN_HOUR = 60; 4 | export const SEC_IN_MIN = 60; 5 | export const HOURS_IN_DAY = 24; 6 | export const SEC_IN_DAY = HOURS_IN_DAY * MIN_IN_HOUR * SEC_IN_MIN; 7 | export const MS_IN_SEC = 1000; 8 | -------------------------------------------------------------------------------- /sdk/src/internal/utils/propertyUtils.ts: -------------------------------------------------------------------------------- 1 | import {UserPropertyKey} from '../../dto/UserPropertyKey'; 2 | 3 | export const convertDefinedUserPropertyKey = (sourceKey: string): UserPropertyKey => { 4 | return Object.values(UserPropertyKey).find(userPropertyKey => userPropertyKey == sourceKey) ?? UserPropertyKey.Custom; 5 | }; 6 | -------------------------------------------------------------------------------- /sdk/src/internal/network/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './NetworkClient'; 3 | export * from './RequestConfigurator'; 4 | export * from './ApiInteractor'; 5 | export * from './HeaderBuilder'; 6 | export * from './RetryDelayCalculator'; 7 | export * from './RetryPolicy'; 8 | export {API_URL} from './constants'; 9 | -------------------------------------------------------------------------------- /fastlane/report.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /sdk/src/internal/common/constants.ts: -------------------------------------------------------------------------------- 1 | export enum StorageConstants { 2 | PendingUserProperties = 'io.qonversion.keys.userProperties.pending', 3 | SentUserProperties = 'io.qonversion.keys.userProperties.sent', 4 | OriginalUserId = 'io.qonversion.keys.originalUserID', 5 | IdentityUserId = 'io.qonversion.keys.identityUserID', 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "module": "commonjs", 5 | "target": "es2017", 6 | "strict": true, 7 | "rootDir": "./sdk/src", 8 | "outDir": "./sdk/build" 9 | }, 10 | "include": [ 11 | "sdk/src/**/*" 12 | ], 13 | "exclude": [ 14 | "sdk/src/__tests__/*" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /sdk/src/dto/Purchase.ts: -------------------------------------------------------------------------------- 1 | export type PurchaseCoreData = { 2 | price: string; 3 | currency: string; // Currency code by ISO 4217 standard 4 | purchased?: number; 5 | }; 6 | 7 | export type StripeStoreData = { 8 | subscriptionId: string; 9 | productId: string; 10 | }; 11 | 12 | export type UserPurchase = PurchaseCoreData & { 13 | purchased: number; 14 | stripeStoreData: StripeStoreData; 15 | userId: string; 16 | }; 17 | -------------------------------------------------------------------------------- /sdk/src/internal/user/UserIdGenerator.ts: -------------------------------------------------------------------------------- 1 | import {UserIdGenerator} from './types'; 2 | import {v4 as generateUuid} from 'uuid'; 3 | import {USER_ID_PREFIX, USER_ID_SEPARATOR} from './constants'; 4 | 5 | export class UserIdGeneratorImpl implements UserIdGenerator { 6 | generate(): string { 7 | const uuid = generateUuid().replace(/-/g, ''); 8 | 9 | return `${USER_ID_PREFIX}${USER_ID_SEPARATOR}${uuid}`; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /sdk/src/__tests__/utils.ts: -------------------------------------------------------------------------------- 1 | import {QonversionError, QonversionErrorCode} from '../index'; 2 | 3 | export function expectQonversionError(code: QonversionErrorCode, method: () => unknown) { 4 | try { 5 | method(); 6 | fail("Exception expected but was not thrown"); 7 | } catch (e) { 8 | expect(e).toBeInstanceOf(QonversionError); 9 | expect((e as QonversionError).code).toBe(code); 10 | } 11 | } 12 | 13 | test('skip', () => {}); 14 | -------------------------------------------------------------------------------- /sdk/src/dto/Environment.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This enum contains different available settings for Environment. 3 | * Provide it to the configuration object via {@link Qonversion.initialize} 4 | * while initializing the SDK or via {@link QonversionInstance.setEnvironment} 5 | * after initializing the SDK. The default value SDK uses is {@link Environment.Production}. 6 | */ 7 | export enum Environment { 8 | Sandbox = 'sandbox', 9 | Production = 'prod', 10 | } 11 | -------------------------------------------------------------------------------- /sdk/src/internal/common/types.ts: -------------------------------------------------------------------------------- 1 | export type LocalStorage = { 2 | getInt(key: string): number | undefined; 3 | getFloat(key: string): number | undefined; 4 | putNumber(key: string, value: number): void; 5 | getString(key: string): string | undefined; 6 | putString(key: string, value: string): void; 7 | putObject(key: string, value: Record): void; 8 | getObject>(key: string): T | undefined; 9 | remove(key: string): void; 10 | }; 11 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint', 6 | ], 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier', 11 | ], 12 | rules: { 13 | '@typescript-eslint/no-inferrable-types': "off", 14 | '@typescript-eslint/no-empty-function': ["warn", { "allow": ["private-constructors"] }], 15 | }, 16 | }; -------------------------------------------------------------------------------- /.github/workflows/prerelease_github.yml: -------------------------------------------------------------------------------- 1 | name: Pre-release Github 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | 8 | jobs: 9 | pre-release: 10 | runs-on: macos-latest 11 | 12 | steps: 13 | - uses: "marvinpinto/action-automatic-releases@latest" 14 | with: 15 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 16 | automatic_release_tag: "latest" 17 | prerelease: true 18 | title: "Development Build" 19 | files: LICENSE.txt 20 | -------------------------------------------------------------------------------- /sdk/src/exception/QonversionErrorCode.ts: -------------------------------------------------------------------------------- 1 | export enum QonversionErrorCode { 2 | ConfigPreparation = "Failed to prepare configuration for SDK initialization", 3 | NotInitialized = "Qonversion has not been initialized. You should call " + 4 | "the initialize method before accessing the shared instance of Qonversion", 5 | RequestDenied = "Request denied", 6 | BackendError = "Qonversion API returned an error", 7 | UserNotFound = "Qonversion user not found", 8 | IdentityNotFound = "User with requested identity not found", 9 | } 10 | -------------------------------------------------------------------------------- /sdk/src/dto/UserProperty.ts: -------------------------------------------------------------------------------- 1 | import {UserPropertyKey} from './UserPropertyKey'; 2 | import {convertDefinedUserPropertyKey} from '../internal/utils/propertyUtils'; 3 | 4 | export class UserProperty { 5 | key: string; 6 | value: string; 7 | 8 | /** 9 | * {@link UserPropertyKey} used to set this property. 10 | * Returns {@link UserPropertyKey.Custom} for custom properties. 11 | */ 12 | definedKey: UserPropertyKey; 13 | 14 | constructor(key: string, value: string) { 15 | this.key = key; 16 | this.value = value; 17 | this.definedKey = convertDefinedUserPropertyKey(key); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /sdk/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './QonversionConfigBuilder'; 2 | export {QonversionConfig, QonversionInstance} from './types'; 3 | export * from './dto/Entitlement'; 4 | export * from './dto/Environment'; 5 | export * from './dto/LogLevel'; 6 | export * from './dto/Purchase'; 7 | export * from './dto/User'; 8 | export * from './dto/UserProperties'; 9 | export * from './dto/UserProperty'; 10 | export * from './dto/UserPropertyKey'; 11 | export * from './exception/QonversionError'; 12 | export * from './exception/QonversionErrorCode'; 13 | export * from './UserPropertiesBuilder'; 14 | export {default} from './Qonversion'; 15 | -------------------------------------------------------------------------------- /sdk/src/internal/types.ts: -------------------------------------------------------------------------------- 1 | import {Environment} from '../dto/Environment'; 2 | import {LogLevel} from '../dto/LogLevel'; 3 | import {PrimaryConfig} from '../types'; 4 | 5 | export type EnvironmentProvider = { 6 | getEnvironment: () => Environment; 7 | isSandbox: () => boolean; 8 | }; 9 | 10 | export type LoggerConfigProvider = { 11 | getLogLevel: () => LogLevel; 12 | getLogTag: () => string; 13 | }; 14 | 15 | export type NetworkConfigHolder = { 16 | canSendRequests: () => boolean; 17 | setCanSendRequests: (canSend: boolean) => void; 18 | }; 19 | 20 | export type PrimaryConfigProvider = { 21 | getPrimaryConfig: () => PrimaryConfig; 22 | }; 23 | -------------------------------------------------------------------------------- /sdk/src/internal/network/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_RETRY_COUNT = 3; 2 | export const DEFAULT_MIN_DELAY_MS = 500; 3 | export const DEBUG_MODE_PREFIX = "test_"; 4 | export const PLATFORM_FOR_API = "web"; 5 | export const API_URL = "https://api.qonversion.io" 6 | 7 | export const MIN_SUCCESS_CODE = 200; 8 | export const MAX_SUCCESS_CODE = 299; 9 | export const MIN_INTERNAL_SERVER_ERROR_CODE = 500; 10 | export const MAX_INTERNAL_SERVER_ERROR_CODE = 599; 11 | export const HTTP_CODE_NOT_FOUND = 404; 12 | 13 | export const ERROR_CODES_BLOCKING_FURTHER_EXECUTIONS = [ 14 | 401, // UNAUTHORIZED, 15 | 402, // PAYMENT_REQUIRED, 16 | 418, // I'M A TEAPOT - for possible api usage. 17 | ]; 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | publish: 9 | name: Upload archives 10 | runs-on: macos-latest 11 | steps: 12 | - name: Check out code 13 | uses: actions/checkout@v4 14 | 15 | - name: Build typescript 16 | run: | 17 | yarn 18 | yarn build 19 | 20 | - name: Setup node for publishing 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: '20.x' 24 | registry-url: 'https://registry.npmjs.org' 25 | 26 | - name: Publish to npm 27 | run: | 28 | npm publish 29 | env: 30 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | -------------------------------------------------------------------------------- /sdk/src/exception/QonversionError.ts: -------------------------------------------------------------------------------- 1 | import {QonversionErrorCode} from './QonversionErrorCode'; 2 | 3 | /** 4 | * Qonversion error that the SDK may throw on some calls. 5 | * 6 | * Check error code and details to get more information about concrete error you handle. 7 | */ 8 | export class QonversionError extends Error { 9 | readonly code: QonversionErrorCode; 10 | readonly cause?: Error; 11 | 12 | constructor(code: QonversionErrorCode, details?: string, cause?: Error) { 13 | let message: string = code; 14 | if (details) { 15 | message += '. ' + details; 16 | } 17 | if (cause) { 18 | message += '. ' + cause.message; 19 | } 20 | 21 | super(message); 22 | 23 | this.code = code; 24 | this.cause = cause; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /sdk/src/internal/purchases/types.ts: -------------------------------------------------------------------------------- 1 | import {PurchaseCoreData, StripeStoreData, UserPurchase} from '../../dto/Purchase'; 2 | 3 | export type PurchasesService = { 4 | sendStripePurchase: (userId: string, data: PurchaseCoreData & StripeStoreData) => Promise; 5 | }; 6 | 7 | export type PurchasesController = { 8 | sendStripePurchase: (data: PurchaseCoreData & StripeStoreData) => Promise; 9 | }; 10 | 11 | export type PurchaseCoreDataApi = { 12 | price: string; 13 | currency: string; 14 | purchased: number; 15 | userId: string; 16 | }; 17 | 18 | export type StripeStoreDataApi = { 19 | subscription_id: string; 20 | product_id: string; 21 | }; 22 | 23 | export type UserPurchaseApi = PurchaseCoreDataApi & { 24 | stripe_store_data: StripeStoreDataApi; 25 | }; 26 | -------------------------------------------------------------------------------- /sdk/src/internal/network/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MAX_INTERNAL_SERVER_ERROR_CODE, 3 | MAX_SUCCESS_CODE, 4 | MIN_INTERNAL_SERVER_ERROR_CODE, 5 | MIN_SUCCESS_CODE 6 | } from './constants'; 7 | 8 | type ResponseCodeChecker = (code: number) => boolean; 9 | export const isSuccessfulResponse: ResponseCodeChecker = code => { 10 | return MIN_SUCCESS_CODE <= code && code <= MAX_SUCCESS_CODE; 11 | }; 12 | 13 | export const isInternalServerErrorResponse: ResponseCodeChecker = code => { 14 | return MIN_INTERNAL_SERVER_ERROR_CODE <= code && code <= MAX_INTERNAL_SERVER_ERROR_CODE; 15 | }; 16 | 17 | type Delayer = (timeMs: number) => Promise; 18 | export const delay: Delayer = timeMs => { 19 | return new Promise(resolve => { 20 | setTimeout(() => { 21 | resolve(); 22 | }, timeMs); 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /sdk/src/internal/network/RetryPolicy.ts: -------------------------------------------------------------------------------- 1 | import {DEFAULT_MIN_DELAY_MS, DEFAULT_RETRY_COUNT} from './constants'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/ban-types 4 | export type RetryPolicy = {}; 5 | 6 | export class RetryPolicyNone implements RetryPolicy {} 7 | 8 | export class RetryPolicyExponential implements RetryPolicy { 9 | readonly retryCount: number; 10 | readonly minDelay: number; 11 | 12 | constructor(retryCount: number = DEFAULT_RETRY_COUNT, minDelay: number = DEFAULT_MIN_DELAY_MS) { 13 | this.retryCount = retryCount; 14 | this.minDelay = minDelay; 15 | } 16 | } 17 | 18 | export class RetryPolicyInfiniteExponential implements RetryPolicy { 19 | readonly minDelay: number; 20 | 21 | constructor(minDelay: number = DEFAULT_MIN_DELAY_MS) { 22 | this.minDelay = minDelay; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sdk/src/internal/entitlements/types.ts: -------------------------------------------------------------------------------- 1 | import {Entitlement, PeriodType, RenewState} from '../../dto/Entitlement'; 2 | 3 | export type EntitlementsService = { 4 | getEntitlements: (userId: string) => Promise; 5 | }; 6 | 7 | export type EntitlementsController = { 8 | getEntitlements: () => Promise; 9 | }; 10 | 11 | export type EntitlementsResponse = { 12 | object: 'list'; 13 | data: EntitlementApi[]; 14 | }; 15 | 16 | export type EntitlementApi = { 17 | id: string; 18 | active: boolean; 19 | started: number; 20 | expires: number; 21 | source: string; 22 | product?: ProductApi; 23 | } 24 | 25 | export type ProductApi = { 26 | product_id: string; 27 | subscription?: SubscriptionApi; 28 | }; 29 | 30 | export type SubscriptionApi = { 31 | renew_state: RenewState; 32 | current_period_type: PeriodType; 33 | }; 34 | -------------------------------------------------------------------------------- /sdk/src/internal/network/RetryDelayCalculator.ts: -------------------------------------------------------------------------------- 1 | import {MS_IN_SEC} from '../utils/dateUtils'; 2 | 3 | export type RetryDelayCalculator = { 4 | countDelay: (minDelay: number, retriesCount: number) => number; 5 | }; 6 | 7 | const JITTER = 0.4 8 | const FACTOR = 2.4 9 | const MAX_DELAY_MS = 1000000 10 | 11 | export class ExponentialDelayCalculator implements RetryDelayCalculator { 12 | private readonly jitter = JITTER; 13 | private readonly factor = FACTOR; 14 | private readonly maxDelayMS = MAX_DELAY_MS; 15 | 16 | countDelay(minDelay: number, retriesCount: number): number { 17 | let delay = Math.floor(minDelay + Math.pow(this.factor, retriesCount) * MS_IN_SEC) 18 | const delta = Math.round(delay * this.jitter); 19 | 20 | delay += Math.floor(Math.random() * (delta + 1)); 21 | 22 | return Math.min(delay, this.maxDelayMS); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sdk/src/dto/Entitlement.ts: -------------------------------------------------------------------------------- 1 | export type Entitlement = { 2 | id: string; 3 | active: boolean; 4 | started: number; 5 | expires?: number | null; 6 | source: EntitlementSource; 7 | product?: Product; 8 | } 9 | 10 | export type Product = { 11 | productId: string; 12 | subscription?: Subscription; 13 | }; 14 | 15 | export type Subscription = { 16 | renewState: RenewState; 17 | currentPeriodType: PeriodType; 18 | }; 19 | 20 | export enum RenewState { 21 | WillRenew = 'will_renew', 22 | Canceled = 'canceled', 23 | BillingIssue = 'billing_issue', 24 | } 25 | 26 | export enum PeriodType { 27 | Normal = 'normal', 28 | Trial = 'trial', 29 | Intro = 'intro', 30 | } 31 | 32 | export enum EntitlementSource { 33 | Unknown = 'unknown', 34 | AppStore = 'appstore', 35 | PlayStore = 'playstore', 36 | Stripe = 'stripe', 37 | Manual = 'manual' 38 | } 39 | -------------------------------------------------------------------------------- /sdk/src/internal/network/NetworkClient.ts: -------------------------------------------------------------------------------- 1 | import {ApiHeader, NetworkClient, NetworkRequest, RawNetworkResponse} from './types'; 2 | 3 | export class NetworkClientImpl implements NetworkClient { 4 | async execute(request: NetworkRequest): Promise { 5 | const headers: HeadersInit = { 6 | ...request.headers, 7 | [ApiHeader.ContentType]: 'application/json', 8 | [ApiHeader.Accept]: 'application/json', 9 | }; 10 | const body: BodyInit | undefined = request.body ? JSON.stringify(request.body) : undefined; 11 | const requestInit: RequestInit = { 12 | method: request.type, 13 | headers, 14 | body, 15 | }; 16 | 17 | const response = await fetch(request.url, requestInit); 18 | const code = response.status; 19 | const data = await response.json(); 20 | return {code, payload: data}; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## Mac 17 | 18 | ### mac bump 19 | 20 | ```sh 21 | [bundle exec] fastlane mac bump 22 | ``` 23 | 24 | 25 | 26 | ### mac setAegisUrl 27 | 28 | ```sh 29 | [bundle exec] fastlane mac setAegisUrl 30 | ``` 31 | 32 | 33 | 34 | ---- 35 | 36 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 37 | 38 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 39 | 40 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 41 | -------------------------------------------------------------------------------- /sdk/src/dto/LogLevel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This enum contains available settings for LogLevel. 3 | * Provide it to the configuration object via {@link Qonversion.initialize} 4 | * while initializing the SDK or via {@link QonversionInstance.setLogLevel} 5 | * after initializing the SDK. The default value SDK uses is {@link LogLevel.Info}. 6 | * 7 | * You could change the log level to make logs from the Qonversion SDK more detailed or strict. 8 | */ 9 | export enum LogLevel { 10 | // All available logs (function started, function finished, data fetched, etc) 11 | Verbose = 0, 12 | 13 | // Info level (data fetched, products loaded, user info fetched, etc) 14 | Info = 10, 15 | 16 | // Warning level (data fetched partially, sandbox env enabled for release build, etc) 17 | Warning = 20, 18 | 19 | // Error level (data fetch failed, Google billing is not available, etc) 20 | Error = 30, 21 | 22 | // Logging is disabled at all 23 | Disabled = Number.MAX_SAFE_INTEGER 24 | } 25 | -------------------------------------------------------------------------------- /sdk/src/__tests__/Qonversion.test.ts: -------------------------------------------------------------------------------- 1 | import {expectQonversionError} from './utils'; 2 | import Qonversion, {QonversionConfig, QonversionErrorCode} from '../index'; 3 | import {QonversionInternal} from '../internal'; 4 | 5 | jest.mock('../internal/QonversionInternal'); 6 | 7 | test('get non-initialized backing instance', () => { 8 | // given 9 | 10 | // when and then 11 | expectQonversionError(QonversionErrorCode.NotInitialized, Qonversion.getSharedInstance) 12 | }); 13 | 14 | test('initialize and get shared instance', () => { 15 | // given 16 | const mockQonversionConfig: QonversionConfig = { 17 | // @ts-ignore 18 | loggerConfig: undefined, 19 | // @ts-ignore 20 | networkConfig: undefined, 21 | // @ts-ignore 22 | primaryConfig: undefined, 23 | }; 24 | 25 | // when 26 | Qonversion.initialize(mockQonversionConfig) 27 | 28 | // then 29 | expect(QonversionInternal).toBeCalled(); 30 | expect(Qonversion['backingInstance']).not.toBeUndefined(); 31 | }); 32 | -------------------------------------------------------------------------------- /sdk/src/internal/di/MiscAssembly.ts: -------------------------------------------------------------------------------- 1 | import {MiscAssembly} from './types'; 2 | import {InternalConfig} from '../InternalConfig'; 3 | import LoggerImpl, {Logger} from '../logger'; 4 | import {ExponentialDelayCalculator, RetryDelayCalculator} from '../network'; 5 | import {DelayedWorker, DelayedWorkerImpl} from '../utils/DelayedWorker'; 6 | import {UserIdGenerator, UserIdGeneratorImpl} from '../user'; 7 | 8 | export class MiscAssemblyImpl implements MiscAssembly { 9 | private readonly internalConfig: InternalConfig; 10 | 11 | constructor(internalConfig: InternalConfig) { 12 | this.internalConfig = internalConfig; 13 | } 14 | 15 | logger(): Logger { 16 | return new LoggerImpl(this.internalConfig); 17 | } 18 | 19 | exponentialDelayCalculator(): RetryDelayCalculator { 20 | return new ExponentialDelayCalculator(); 21 | } 22 | 23 | delayedWorker(): DelayedWorker { 24 | return new DelayedWorkerImpl(); 25 | } 26 | 27 | userIdGenerator(): UserIdGenerator { 28 | return new UserIdGeneratorImpl(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Qonversion 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /sdk/src/__integrationTests__/constants.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | // Load environment variables from .env file 4 | dotenv.config(); 5 | 6 | export const AEGIS_URL = process.env.AEGIS_URL || ''; 7 | export const STRIPE_PRODUCT_ID = process.env.STRIPE_PRODUCT_ID || ''; 8 | export const STRIPE_SUBSCRIPTION_ID = process.env.STRIPE_SUBSCRIPTION_ID || ''; 9 | export const PROJECT_KEY_FOR_TESTS = 'PV77YHL7qnGvsdmpTs7gimsxUvY-Znl2'; 10 | export const PRIVATE_TOKEN_FOR_TESTS = 'sk_Yp-HzIBFO_mZE0YqNBeQLDrQoWmvJvSt'; 11 | export const TS_EPSILON = 100; 12 | 13 | // No-Codes test constants 14 | export const PROJECT_KEY_FOR_SCREENS = "V4pK6FQo3PiDPj_2vYO1qZpNBbFXNP-a"; 15 | export const INCORRECT_PROJECT_KEY_FOR_SCREENS = "V4pK6FQo3PiDPj_2vYO1qZpNBbFXNP-aaaaa"; 16 | export const VALID_CONTEXT_KEY = "test_context_key"; 17 | export const ID_FOR_SCREEN_BY_CONTEXT_KEY = "KBxnTzQs"; 18 | export const NON_EXISTENT_CONTEXT_KEY = "non_existent_test_context_key"; 19 | export const VALID_SCREEN_ID = "RkgXghGq"; 20 | export const CONTEXT_KEY_FOR_SCREEN_BY_ID = "another_test_context_key"; 21 | export const NON_EXISTENT_SCREEN_ID = "non_existent_screen_id"; 22 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/user/UserIdGenerator.test.ts: -------------------------------------------------------------------------------- 1 | import {UserIdGenerator, UserIdGeneratorImpl} from '../../../internal/user'; 2 | import {USER_ID_PREFIX, USER_ID_SEPARATOR} from '../../../internal/user/constants'; 3 | 4 | const testUuid = 'b431fcbe-b067-4be0-9288-4a19887522e8'; 5 | 6 | jest.mock('uuid', () => { 7 | const originalModule = jest.requireActual('uuid'); 8 | 9 | return { 10 | __esModule: true, 11 | ...originalModule, 12 | v4: jest.fn(() => testUuid), 13 | }; 14 | }); 15 | 16 | import {v4 as uuidGenerator} from 'uuid'; 17 | 18 | describe('UserIdGenerator tests', function () { 19 | let userIdGenerator: UserIdGenerator; 20 | 21 | beforeEach(() => { 22 | userIdGenerator = new UserIdGeneratorImpl(); 23 | }); 24 | 25 | test('generate user id', () => { 26 | // given 27 | const uuidWithoutDashes = 'b431fcbeb0674be092884a19887522e8'; 28 | const expRes = `${USER_ID_PREFIX}${USER_ID_SEPARATOR}${uuidWithoutDashes}`; 29 | 30 | // when 31 | const res = userIdGenerator.generate(); 32 | 33 | // then 34 | expect(res).toBe(expRes); 35 | expect(uuidGenerator).toBeCalled(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /sdk/src/dto/UserPropertyKey.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This enum class represents all defined user property keys 3 | * that can be assigned to the user. Provide these keys along with values 4 | * to {@link QonversionInstance.setUserProperty} method. 5 | * See [the documentation](https://documentation.qonversion.io/docs/web-sdk#properties) for more information 6 | */ 7 | export enum UserPropertyKey { 8 | Email = "_q_email", 9 | Name = "_q_name", 10 | KochavaDeviceId = "_q_kochava_device_id", 11 | AppsFlyerUserId = "_q_appsflyer_user_id", 12 | AdjustAdId = "_q_adjust_adid", 13 | CustomUserId = "_q_custom_user_id", 14 | FacebookAttribution = "_q_fb_attribution", // Android only 15 | FirebaseAppInstanceId = "_q_firebase_instance_id", 16 | AppSetId = "_q_app_set_id", // Android only 17 | AdvertisingId = "_q_advertising_id", // iOS only 18 | AppMetricaDeviceId = "_q_appmetrica_device_id", 19 | AppMetricaUserProfileId = "_q_appmetrica_user_profile_id", 20 | PushWooshHwId = "_q_pushwoosh_hwid", 21 | PushWooshUserId = "_q_pushwoosh_user_id", 22 | TenjinAnalyticsInstallationId = "_q_tenjin_aiid", 23 | Custom = "", 24 | } 25 | -------------------------------------------------------------------------------- /sdk/src/internal/userProperties/types.ts: -------------------------------------------------------------------------------- 1 | import {UserProperties} from '../../dto/UserProperties'; 2 | 3 | export type UserPropertiesStorage = { 4 | getProperties: () => Record; 5 | 6 | addOne: (key: string, value: string) => void; 7 | 8 | add: (properties: Record) => void; 9 | 10 | deleteOne: (key: string, value: string) => void; 11 | 12 | delete: (properties: Record) => void; 13 | 14 | clear: () => void; 15 | }; 16 | 17 | export type UserPropertiesService = { 18 | sendProperties: (userId: string, properties: Record) => Promise; 19 | getProperties: (userId: string) => Promise; 20 | }; 21 | 22 | export type UserPropertiesController = { 23 | setProperty: (key: string, value: string) => void; 24 | setProperties: (properties: Record) => void; 25 | getProperties: () => Promise; 26 | }; 27 | 28 | export type UserPropertyData = { 29 | key: string; 30 | value: string; 31 | }; 32 | 33 | export type UserPropertyError = { 34 | key: string; 35 | error: string; 36 | }; 37 | 38 | export type UserPropertiesSendResponse = { 39 | savedProperties: UserPropertyData[], 40 | propertyErrors: UserPropertyError[], 41 | }; 42 | -------------------------------------------------------------------------------- /.github/workflows/integration_tests.yml: -------------------------------------------------------------------------------- 1 | name: Planned integration tests 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 3 * * *' 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | 12 | concurrency: 13 | group: web_integration_tests 14 | cancel-in-progress: true 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set Aegis url 19 | run: | 20 | fastlane setAegisUrl url:${{ secrets.AEGIS_URL }} 21 | 22 | - name: Set Stripe secrets 23 | run: | 24 | fastlane updateStripeSecrets product_id:${{ vars.STRIPE_PRODUCT_ID }} subscription_id:${{ vars.STRIPE_SUBSCRIPTION_ID }} 25 | 26 | - name: Use Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: '20.x' 30 | 31 | - name: Cache NPM # leverage npm cache on repeated workflow runs if package.json didn't change 32 | uses: actions/cache@v4 33 | with: 34 | path: ~/.npm 35 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 36 | restore-keys: | 37 | ${{ runner.os }}-node- 38 | - name: Install Dependencies 39 | run: yarn 40 | 41 | - name: Tests 42 | run: | 43 | yarn integrationTests 44 | -------------------------------------------------------------------------------- /sdk/src/internal/user/UserServiceDecorator.ts: -------------------------------------------------------------------------------- 1 | import {UserService} from './types'; 2 | import {User} from '../../dto/User'; 3 | import {QonversionError} from '../../exception/QonversionError'; 4 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 5 | 6 | export class UserServiceDecorator implements UserService { 7 | private readonly userService: UserService; 8 | 9 | private userLoadingPromise: Promise | undefined = undefined; 10 | 11 | constructor(userService: UserService) { 12 | this.userService = userService; 13 | } 14 | 15 | async createUser(id: string): Promise { 16 | return await this.userService.createUser(id); 17 | } 18 | 19 | async getUser(id: string): Promise { 20 | if (this.userLoadingPromise) { 21 | return await this.userLoadingPromise; 22 | } 23 | 24 | this.userLoadingPromise = this.loadOrCreateUser(id); 25 | 26 | return await this.userLoadingPromise; 27 | } 28 | 29 | private async loadOrCreateUser(id: string): Promise { 30 | try { 31 | return await this.userService.getUser(id); 32 | } catch (e) { 33 | if (e instanceof QonversionError && e.code == QonversionErrorCode.UserNotFound) { 34 | return await this.userService.createUser(id); 35 | } 36 | throw e; 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /sdk/src/internal/utils/objectUtils.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | type CamelCaseKeys = (value: any) => T; 3 | export const camelCaseKeys: CamelCaseKeys = value => { 4 | let convertedValue; 5 | if (Array.isArray(value)) { 6 | convertedValue = value.map(arrayValue => camelCaseKeys(arrayValue)); 7 | } else if (typeof value === 'object') { 8 | convertedValue = camelCaseObjectKeys(value); 9 | } else { 10 | convertedValue = value; 11 | } 12 | return convertedValue; 13 | } 14 | 15 | type CamelCaseObjectKeys = >(obj: Record) => T; 16 | export const camelCaseObjectKeys: CamelCaseObjectKeys = (obj: Record) => { 17 | const keys = Object.keys(obj); 18 | const result: Record = {}; 19 | keys.forEach(key => { 20 | const value = obj[key]; 21 | const camelcaseKey = snakeToCamelCase(key); 22 | result[camelcaseKey] = camelCaseKeys(value); 23 | }); 24 | return result as T; 25 | }; 26 | 27 | type SnakeToCamelCaseConverter = (str: string) => string; 28 | export const snakeToCamelCase: SnakeToCamelCaseConverter = str => 29 | str.replace(/([-_][a-zA-Z])/g, group => 30 | group 31 | .toUpperCase() 32 | .replace('-', '') 33 | .replace('_', '') 34 | ); 35 | -------------------------------------------------------------------------------- /sdk/src/internal/logger/logger.ts: -------------------------------------------------------------------------------- 1 | import {Logger, LogMethod} from './types'; 2 | import {LoggerConfigProvider} from '../types'; 3 | import {LogLevel} from '../../dto/LogLevel'; 4 | 5 | export default class LoggerImpl implements Logger { 6 | private readonly loggerConfigProvider: LoggerConfigProvider; 7 | 8 | constructor(loggerConfigProvider: LoggerConfigProvider) { 9 | this.loggerConfigProvider = loggerConfigProvider; 10 | } 11 | 12 | verbose(message: string, ...objects: unknown[]) { 13 | this.log(LogLevel.Verbose, console.log, message, objects); 14 | } 15 | 16 | info(message: string, ...objects: unknown[]) { 17 | this.log(LogLevel.Info, console.info, message, objects); 18 | } 19 | 20 | warn(message: string, ...objects: unknown[]) { 21 | this.log(LogLevel.Warning, console.warn, message, objects); 22 | } 23 | 24 | error(message: string, ...objects: unknown[]) { 25 | this.log(LogLevel.Error, console.error, message, objects); 26 | } 27 | 28 | private log(logLevel: LogLevel, logMethod: LogMethod, message: string, objects: unknown[]) { 29 | const allowedLogLevel = this.loggerConfigProvider.getLogLevel(); 30 | if (logLevel >= allowedLogLevel) { 31 | const logMessage = `${this.loggerConfigProvider.getLogTag()}: ${message}`; 32 | logMethod(logMessage, ...objects); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /sdk/src/internal/purchases/PurchasesController.ts: -------------------------------------------------------------------------------- 1 | import {PurchasesController, PurchasesService} from './types'; 2 | import {UserDataStorage} from '../user'; 3 | import {Logger} from '../logger'; 4 | import {PurchaseCoreData, StripeStoreData, UserPurchase} from '../../dto/Purchase'; 5 | 6 | export class PurchasesControllerImpl implements PurchasesController { 7 | private readonly purchasesService: PurchasesService; 8 | private readonly userDataStorage: UserDataStorage; 9 | private readonly logger: Logger; 10 | 11 | constructor(purchasesService: PurchasesService, userDataStorage: UserDataStorage, logger: Logger) { 12 | this.purchasesService = purchasesService; 13 | this.userDataStorage = userDataStorage; 14 | this.logger = logger; 15 | } 16 | 17 | async sendStripePurchase(data: PurchaseCoreData & StripeStoreData): Promise { 18 | try { 19 | const userId = this.userDataStorage.requireOriginalUserId(); 20 | this.logger.verbose('Sending Stripe purchase', {userId, data}); 21 | const userPurchase = await this.purchasesService.sendStripePurchase(userId, data); 22 | this.logger.info('Successfully sent the Stripe purchase', userPurchase); 23 | return userPurchase; 24 | } catch (error) { 25 | this.logger.error('Failed to send the Stripe purchase', error); 26 | throw error; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /sdk/src/internal/utils/DelayedWorker.ts: -------------------------------------------------------------------------------- 1 | export type DelayedWorker = { 2 | doDelayed: (delayMs: number, action: () => Promise, ignoreExistingJob?: boolean) => void; 3 | 4 | doImmediately: (action: () => Promise) => void; 5 | 6 | cancel: () => void; 7 | 8 | isInProgress: () => boolean; 9 | }; 10 | 11 | export class DelayedWorkerImpl implements DelayedWorker { 12 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 13 | private timeoutId: any | undefined; // timeout type differs for different engines 14 | 15 | doDelayed(delayMs: number, action: () => Promise, ignoreExistingJob: boolean = false): void { 16 | if (ignoreExistingJob && this.isInProgress()) { 17 | this.cancel(); 18 | } 19 | 20 | if (!this.isInProgress()) { 21 | this.timeoutId = setTimeout(async () => { 22 | try { 23 | await action(); 24 | } finally { 25 | this.timeoutId = undefined; 26 | } 27 | }, delayMs); 28 | } 29 | } 30 | 31 | doImmediately(action: () => Promise): void { 32 | this.cancel(); 33 | action().then(() => {/* do nothing */}); 34 | } 35 | 36 | cancel(): void { 37 | if (this.timeoutId) { 38 | clearTimeout(this.timeoutId); 39 | this.timeoutId = undefined; 40 | } 41 | } 42 | 43 | isInProgress(): boolean { 44 | return !!this.timeoutId; 45 | } 46 | } -------------------------------------------------------------------------------- /sdk/src/internal/purchases/PurchaseService.ts: -------------------------------------------------------------------------------- 1 | import {PurchasesService, UserPurchaseApi} from './types'; 2 | import {PurchaseCoreData, StripeStoreData, UserPurchase} from '../../dto/Purchase'; 3 | import {ApiInteractor, RequestConfigurator} from '../network'; 4 | import {camelCaseKeys} from '../utils/objectUtils'; 5 | import {QonversionError} from '../../exception/QonversionError'; 6 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 7 | 8 | export class PurchaseServiceImpl implements PurchasesService { 9 | private readonly requestConfigurator: RequestConfigurator; 10 | private readonly apiInteractor: ApiInteractor; 11 | 12 | constructor(requestConfigurator: RequestConfigurator, apiInteractor: ApiInteractor) { 13 | this.requestConfigurator = requestConfigurator; 14 | this.apiInteractor = apiInteractor; 15 | } 16 | 17 | async sendStripePurchase(userId: string, data: PurchaseCoreData & StripeStoreData): Promise { 18 | const request = this.requestConfigurator.configureStripePurchaseRequest(userId, data); 19 | const response = await this.apiInteractor.execute(request); 20 | 21 | if (response.isSuccess) { 22 | return camelCaseKeys(response.data); 23 | } 24 | 25 | const errorMessage = `Response code ${response.code}, message: ${response.message}`; 26 | throw new QonversionError(QonversionErrorCode.BackendError, errorMessage); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /sdk/src/internal/network/HeaderBuilder.ts: -------------------------------------------------------------------------------- 1 | import {ApiHeader, HeaderBuilder, RequestHeaders} from './types'; 2 | import {EnvironmentProvider, PrimaryConfigProvider} from '../types'; 3 | import {DEBUG_MODE_PREFIX, PLATFORM_FOR_API} from './constants'; 4 | import {UserDataProvider} from '../user'; 5 | 6 | export class HeaderBuilderImpl implements HeaderBuilder { 7 | private readonly primaryConfigProvider: PrimaryConfigProvider; 8 | private readonly environmentProvider: EnvironmentProvider; 9 | private readonly userDataProvider: UserDataProvider; 10 | 11 | constructor(primaryConfigProvider: PrimaryConfigProvider, environmentProvider: EnvironmentProvider, userDataProvider: UserDataProvider) { 12 | this.primaryConfigProvider = primaryConfigProvider; 13 | this.environmentProvider = environmentProvider; 14 | this.userDataProvider = userDataProvider; 15 | } 16 | 17 | buildCommonHeaders(): RequestHeaders { 18 | const baseProjectKey = this.primaryConfigProvider.getPrimaryConfig().projectKey; 19 | const projectKey = this.environmentProvider.isSandbox() ? DEBUG_MODE_PREFIX + baseProjectKey : baseProjectKey; 20 | const bearer = 'Bearer ' + projectKey; 21 | 22 | return { 23 | [ApiHeader.Authorization]: bearer, 24 | [ApiHeader.Platform]: PLATFORM_FOR_API, 25 | [ApiHeader.PlatformVersion]: this.primaryConfigProvider.getPrimaryConfig().sdkVersion, 26 | [ApiHeader.UserID]: this.userDataProvider.getOriginalUserId() ?? '', 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /sdk/src/internal/InternalConfig.ts: -------------------------------------------------------------------------------- 1 | import {EnvironmentProvider, LoggerConfigProvider, NetworkConfigHolder, PrimaryConfigProvider} from './types'; 2 | import {Environment} from '../dto/Environment'; 3 | import {LoggerConfig, NetworkConfig, PrimaryConfig, QonversionConfig} from '../types'; 4 | import {LogLevel} from '../dto/LogLevel'; 5 | 6 | export class InternalConfig implements PrimaryConfigProvider, NetworkConfigHolder, LoggerConfigProvider, EnvironmentProvider { 7 | primaryConfig: PrimaryConfig; 8 | readonly networkConfig: NetworkConfig; 9 | loggerConfig: LoggerConfig; 10 | 11 | constructor(qonversionConfig: QonversionConfig) { 12 | this.primaryConfig = qonversionConfig.primaryConfig; 13 | this.networkConfig = qonversionConfig.networkConfig; 14 | this.loggerConfig = qonversionConfig.loggerConfig; 15 | } 16 | 17 | canSendRequests(): boolean { 18 | return this.networkConfig.canSendRequests; 19 | } 20 | 21 | setCanSendRequests(canSend: boolean) { 22 | this.networkConfig.canSendRequests = canSend; 23 | } 24 | 25 | getEnvironment(): Environment { 26 | return this.primaryConfig.environment; 27 | } 28 | 29 | getLogLevel(): LogLevel { 30 | return this.loggerConfig.logLevel; 31 | } 32 | 33 | getLogTag(): string { 34 | return this.loggerConfig.logTag; 35 | } 36 | 37 | getPrimaryConfig(): PrimaryConfig { 38 | return this.primaryConfig; 39 | } 40 | 41 | isSandbox(): boolean { 42 | return this.getEnvironment() == Environment.Sandbox; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/network/RetryDelayCalculator.test.ts: -------------------------------------------------------------------------------- 1 | import {ExponentialDelayCalculator} from '../../../internal/network'; 2 | 3 | describe('ExponentialDelayCalculator tests', () => { 4 | const delayCalculator = new ExponentialDelayCalculator(); 5 | 6 | test('common cases', () => { 7 | // given 8 | const cases = [[0, 0], [1000, 0]]; 9 | 10 | cases.forEach(testCase => { 11 | const minDelay = testCase[0]; 12 | const retriesCount = testCase[1]; 13 | 14 | // when 15 | const res = delayCalculator.countDelay(minDelay, retriesCount); 16 | 17 | // then 18 | expect(res).toBeGreaterThan(minDelay); 19 | }); 20 | }); 21 | 22 | test('every next attempt is delayed more then previous ones', () => { 23 | // given 24 | const minDelay = 1000; 25 | 26 | // when 27 | const delay1 = delayCalculator.countDelay(minDelay, 0) 28 | const delay2 = delayCalculator.countDelay(minDelay, 1) 29 | const delay3 = delayCalculator.countDelay(minDelay, 2) 30 | 31 | // then 32 | expect(delay1).toBeGreaterThanOrEqual(minDelay); 33 | expect(delay2).toBeGreaterThanOrEqual(delay1); 34 | expect(delay3).toBeGreaterThanOrEqual(delay2); 35 | expect(delay3 - delay2).toBeGreaterThanOrEqual(delay2 - delay1); 36 | }); 37 | 38 | test('huge attempt index', () => { 39 | // given 40 | 41 | // when 42 | const delay = delayCalculator.countDelay(0, 10000000000); 43 | 44 | // then 45 | expect(delay).toBe(1000000); // MAX_DELAY_MS 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /sdk/src/internal/common/LocalStorage.ts: -------------------------------------------------------------------------------- 1 | import {LocalStorage} from './types'; 2 | 3 | export class LocalStorageImpl implements LocalStorage { 4 | getInt(key: string): number | undefined { 5 | const stringValue = this.getString(key); 6 | if (stringValue) { 7 | return Number.parseInt(stringValue); 8 | } 9 | return undefined; 10 | } 11 | 12 | getFloat(key: string): number | undefined { 13 | const stringValue = this.getString(key); 14 | if (stringValue) { 15 | return Number.parseFloat(stringValue); 16 | } 17 | return undefined; 18 | } 19 | 20 | getString(key: string): string | undefined { 21 | return localStorage.getItem(key) ?? undefined; 22 | } 23 | 24 | getObject>(key: string): T | undefined { 25 | const stringValue = this.getString(key); 26 | if (stringValue) { 27 | try { 28 | return JSON.parse(stringValue); 29 | } catch (e) { 30 | // do nothing. 31 | } 32 | } 33 | return undefined; 34 | } 35 | 36 | putObject(key: string, value: Record) { 37 | try { 38 | const stringValue = JSON.stringify(value); 39 | this.putString(key, stringValue); 40 | } catch (e) { 41 | // do nothing. 42 | } 43 | } 44 | 45 | putNumber(key: string, value: number) { 46 | this.putString(key, value.toString()); 47 | } 48 | 49 | putString(key: string, value: string) { 50 | localStorage.setItem(key, value); 51 | } 52 | 53 | remove(key: string) { 54 | localStorage.removeItem(key); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /sdk/src/internal/entitlements/EntitlementsService.ts: -------------------------------------------------------------------------------- 1 | import {EntitlementsResponse, EntitlementsService} from './types'; 2 | import {Entitlement} from '../../dto/Entitlement'; 3 | import {ApiInteractor, RequestConfigurator} from '../network'; 4 | import {camelCaseKeys} from '../utils/objectUtils'; 5 | import {QonversionError} from '../../exception/QonversionError'; 6 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 7 | import {HTTP_CODE_NOT_FOUND} from '../network/constants'; 8 | 9 | export class EntitlementsServiceImpl implements EntitlementsService { 10 | private readonly requestConfigurator: RequestConfigurator; 11 | private readonly apiInteractor: ApiInteractor; 12 | 13 | constructor(requestConfigurator: RequestConfigurator, apiInteractor: ApiInteractor) { 14 | this.requestConfigurator = requestConfigurator; 15 | this.apiInteractor = apiInteractor; 16 | } 17 | 18 | async getEntitlements(userId: string): Promise { 19 | const request = this.requestConfigurator.configureEntitlementsRequest(userId); 20 | const response = await this.apiInteractor.execute(request); 21 | 22 | if (response.isSuccess) { 23 | return camelCaseKeys(response.data.data); 24 | } 25 | 26 | if (response.code == HTTP_CODE_NOT_FOUND) { 27 | throw new QonversionError(QonversionErrorCode.UserNotFound, `User id: ${userId}`); 28 | } 29 | 30 | const errorMessage = `Response code ${response.code}, message: ${response.message}`; 31 | throw new QonversionError(QonversionErrorCode.BackendError, errorMessage); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /sdk/src/internal/user/types.ts: -------------------------------------------------------------------------------- 1 | import {User} from '../../dto/User'; 2 | 3 | export type UserDataProvider = { 4 | getOriginalUserId: () => string | undefined; 5 | 6 | getIdentityUserId: () => string | undefined; 7 | 8 | requireOriginalUserId: () => string; 9 | } 10 | 11 | export type UserDataStorage = UserDataProvider & { 12 | setOriginalUserId: (originalUserId: string) => void; 13 | 14 | setIdentityUserId: (identityUserId: string) => void; 15 | 16 | clearIdentityUserId: () => void; 17 | }; 18 | 19 | export type UserIdGenerator = { 20 | generate: () => string; 21 | }; 22 | 23 | export type UserService = { 24 | getUser: (id: string) => Promise 25 | createUser: (id: string) => Promise 26 | }; 27 | 28 | export type IdentityService = { 29 | createIdentity: (qonversionId: string, identityId: string) => Promise; 30 | obtainIdentity: (identityId: string) => Promise; 31 | }; 32 | 33 | export type UserController = UserChangedNotifier & { 34 | getUser: () => Promise; 35 | createUser: () => Promise; 36 | identify: (identityId: string) => Promise; 37 | logout: () => Promise; 38 | }; 39 | 40 | export type UserApi = { 41 | id: string, 42 | identity_id: string, 43 | created: number, 44 | environment: 'prod' | 'sandbox', 45 | }; 46 | 47 | export type IdentityApi = { 48 | user_id: string; 49 | }; 50 | 51 | export type UserChangedListener = { 52 | onUserChanged: (newUserOriginalId: string, oldUserOriginalId?: string, oldUserIdentityId?: string) => void; 53 | }; 54 | 55 | export type UserChangedNotifier = { 56 | subscribeOnUserChanges: (listener: UserChangedListener) => void; 57 | } 58 | -------------------------------------------------------------------------------- /sdk/src/internal/userProperties/UserPropertiesStorage.ts: -------------------------------------------------------------------------------- 1 | import {UserPropertiesStorage} from './types'; 2 | import {LocalStorage} from '../common'; 3 | 4 | export class UserPropertiesStorageImpl implements UserPropertiesStorage { 5 | private readonly localStorage: LocalStorage; 6 | private readonly key: string; 7 | 8 | private properties: Record = {}; 9 | 10 | constructor(localStorage: LocalStorage, key: string) { 11 | this.localStorage = localStorage; 12 | this.key = key; 13 | 14 | this.properties = localStorage.getObject(this.key) ?? {}; 15 | } 16 | 17 | add(properties: Record): void { 18 | this.properties = { 19 | ...this.properties, 20 | ...properties, 21 | }; 22 | this.saveProperties(); 23 | } 24 | 25 | addOne(key: string, value: string): void { 26 | this.properties[key] = value; 27 | this.saveProperties(); 28 | } 29 | 30 | delete(properties: Record): void { 31 | Object.keys(properties).forEach(key => { 32 | if (this.properties[key] == properties[key]) { 33 | delete this.properties[key]; 34 | } 35 | }); 36 | 37 | this.saveProperties(); 38 | } 39 | 40 | deleteOne(key: string, value: string): void { 41 | if (this.properties[key] == value) { 42 | delete this.properties[key]; 43 | this.saveProperties(); 44 | } 45 | } 46 | 47 | clear(): void { 48 | this.properties = {}; 49 | this.saveProperties(); 50 | } 51 | 52 | getProperties(): Record { 53 | return this.properties; 54 | } 55 | 56 | private saveProperties() { 57 | this.localStorage.putObject(this.key, this.properties); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/utils/objectUtils.test.ts: -------------------------------------------------------------------------------- 1 | import {camelCaseObjectKeys, snakeToCamelCase} from '../../../internal/utils/objectUtils'; 2 | 3 | describe('snakeToCamelCase tests', function () { 4 | test('simple case', () => { 5 | // given 6 | const testCases: Record = { 7 | snake_case_string: 'snakeCaseString', 8 | snake: 'snake', 9 | snake_CASE_string: 'snakeCASEString', 10 | SNAKE_CASE_STRING: 'SNAKECASESTRING', 11 | snake_number_1: 'snakeNumber_1' 12 | }; 13 | 14 | Object.keys(testCases).forEach(givenStr => { 15 | const expStr = testCases[givenStr]; 16 | 17 | // when 18 | const res = snakeToCamelCase(givenStr); 19 | 20 | // then 21 | expect(res).toBe(expStr); 22 | }); 23 | }); 24 | }); 25 | 26 | describe('camelcaseKeys tests', () => { 27 | test('', () => { 28 | // given 29 | const testCases = [ 30 | [{ 31 | key_1: 'a', 32 | aaa: 'aaa', 33 | be_be_be: 'beBeBe' 34 | }, { 35 | key_1: 'a', 36 | aaa: 'aaa', 37 | beBeBe: 'beBeBe' 38 | }], 39 | 40 | [{ 41 | key_1: ['a', 'b', {aa_bb_cc: 'aaBbCc'}], 42 | aaa: 'aaa', 43 | be_be_be: { 44 | sub_object: 1, 45 | sub_object2: [[{test_key: undefined}]], 46 | } 47 | }, { 48 | key_1: ['a', 'b', {aaBbCc: 'aaBbCc'}], 49 | aaa: 'aaa', 50 | beBeBe: { 51 | subObject: 1, 52 | subObject2: [[{testKey: undefined}]], 53 | } 54 | }], 55 | ]; 56 | 57 | testCases.forEach(testCase => { 58 | // when 59 | const res = camelCaseObjectKeys(testCase[0]); 60 | 61 | // then 62 | expect(res).toStrictEqual(testCase[1]); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /sdk/src/internal/user/UserDataStorage.ts: -------------------------------------------------------------------------------- 1 | import {UserDataStorage} from './types'; 2 | import {LocalStorage, StorageConstants} from '../common'; 3 | import {QonversionError} from '../../exception/QonversionError'; 4 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 5 | 6 | export class UserDataStorageImpl implements UserDataStorage { 7 | private readonly localStorage: LocalStorage; 8 | 9 | private originalId: string | undefined; 10 | 11 | private identityId: string | undefined; 12 | 13 | constructor(localStorage: LocalStorage) { 14 | this.localStorage = localStorage; 15 | 16 | this.originalId = localStorage.getString(StorageConstants.OriginalUserId); 17 | this.identityId = localStorage.getString(StorageConstants.IdentityUserId); 18 | } 19 | 20 | getIdentityUserId(): string | undefined { 21 | return this.identityId; 22 | } 23 | 24 | getOriginalUserId(): string | undefined { 25 | return this.originalId; 26 | } 27 | 28 | requireOriginalUserId(): string { 29 | const id = this.getOriginalUserId(); 30 | if (id) { 31 | return id; 32 | } 33 | 34 | throw new QonversionError(QonversionErrorCode.UserNotFound, "The user id was required but does not exist."); 35 | } 36 | 37 | clearIdentityUserId(): void { 38 | this.localStorage.remove(StorageConstants.IdentityUserId); 39 | this.identityId = undefined; 40 | } 41 | 42 | setIdentityUserId(identityUserId: string): void { 43 | this.localStorage.putString(StorageConstants.IdentityUserId, identityUserId); 44 | this.identityId = identityUserId; 45 | } 46 | 47 | setOriginalUserId(originalUserId: string): void { 48 | this.localStorage.putString(StorageConstants.OriginalUserId, originalUserId); 49 | this.originalId = originalUserId; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /sdk/src/__integrationTests__/utils.ts: -------------------------------------------------------------------------------- 1 | import {QonversionErrorCode} from '../exception/QonversionErrorCode'; 2 | import {QonversionError} from '../exception/QonversionError'; 3 | import {DependenciesAssembly, DependenciesAssemblyBuilder} from '../internal/di/DependenciesAssembly'; 4 | import {QonversionConfigBuilder} from '../QonversionConfigBuilder'; 5 | import {PROJECT_KEY_FOR_TESTS} from './constants'; 6 | import {InternalConfig} from '../internal'; 7 | import {Environment} from '../dto/Environment'; 8 | 9 | export const getCurrentTs = (): number => Math.floor(Date.now() / 1000); 10 | 11 | export const getDependencyAssembly = (config: {apiUrl?: string, environment?: Environment} = {}): DependenciesAssembly => { 12 | const qonversionConfig = new QonversionConfigBuilder(PROJECT_KEY_FOR_TESTS) 13 | .setEnvironment(config.environment ?? Environment.Production) 14 | .build(); 15 | if (config.apiUrl) { 16 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 17 | // @ts-ignore 18 | // noinspection JSConstantReassignment 19 | qonversionConfig.networkConfig.apiUrl = config.apiUrl; 20 | } 21 | const internalConfig = new InternalConfig(qonversionConfig); 22 | return new DependenciesAssemblyBuilder(internalConfig).build(); 23 | }; 24 | 25 | export const expectQonversionErrorAsync = async (code: QonversionErrorCode, message: string, method: () => Promise) => { 26 | try { 27 | await method(); 28 | } catch (e) { 29 | expect(e).toBeInstanceOf(QonversionError); 30 | expect((e as QonversionError).code).toBe(code); 31 | expect((e as QonversionError).message).toBe(message); 32 | return; 33 | } 34 | fail("Exception expected but was not thrown"); 35 | } 36 | 37 | const fail = (reason = "Fail was called in a test") => { 38 | throw new Error(reason); 39 | }; 40 | 41 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 42 | // @ts-ignore 43 | global.fail = fail; 44 | -------------------------------------------------------------------------------- /sdk/src/internal/di/StorageAssembly.ts: -------------------------------------------------------------------------------- 1 | import {StorageAssembly} from './types'; 2 | import {UserDataProvider, UserDataStorage, UserDataStorageImpl} from '../user'; 3 | import {LocalStorage, LocalStorageImpl, StorageConstants} from '../common'; 4 | import {UserPropertiesStorage, UserPropertiesStorageImpl} from '../userProperties'; 5 | 6 | export class StorageAssemblyImpl implements StorageAssembly { 7 | private sharedUserDataStorage: UserDataStorage | undefined; 8 | private sharedPendingUserPropertiesStorage: UserPropertiesStorage | undefined; 9 | private sharedSentUserPropertiesStorage: UserPropertiesStorage | undefined; 10 | 11 | localStorage(): LocalStorage { 12 | return new LocalStorageImpl(); 13 | } 14 | 15 | userDataProvider(): UserDataProvider { 16 | return this.userDataStorage(); 17 | } 18 | 19 | userDataStorage(): UserDataStorage { 20 | if (this.sharedUserDataStorage) { 21 | return this.sharedUserDataStorage; 22 | } 23 | this.sharedUserDataStorage = new UserDataStorageImpl(this.localStorage()); 24 | return this.sharedUserDataStorage; 25 | } 26 | 27 | pendingUserPropertiesStorage(): UserPropertiesStorage { 28 | if (this.sharedPendingUserPropertiesStorage) { 29 | return this.sharedPendingUserPropertiesStorage; 30 | } 31 | this.sharedPendingUserPropertiesStorage = this.userPropertiesStorage(StorageConstants.PendingUserProperties); 32 | return this.sharedPendingUserPropertiesStorage; 33 | } 34 | 35 | sentUserPropertiesStorage(): UserPropertiesStorage { 36 | if (this.sharedSentUserPropertiesStorage) { 37 | return this.sharedSentUserPropertiesStorage; 38 | } 39 | this.sharedSentUserPropertiesStorage = this.userPropertiesStorage(StorageConstants.SentUserProperties); 40 | return this.sharedSentUserPropertiesStorage; 41 | } 42 | 43 | private userPropertiesStorage(key: string): UserPropertiesStorage { 44 | return new UserPropertiesStorageImpl(this.localStorage(), key); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /sdk/src/internal/user/IdentityService.ts: -------------------------------------------------------------------------------- 1 | import {IdentityApi, IdentityService} from './types'; 2 | import {ApiInteractor, RequestConfigurator} from '../network'; 3 | import {QonversionError} from '../../exception/QonversionError'; 4 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 5 | import {HTTP_CODE_NOT_FOUND} from '../network/constants'; 6 | 7 | export class IdentityServiceImpl implements IdentityService { 8 | private readonly requestConfigurator: RequestConfigurator; 9 | private readonly apiInteractor: ApiInteractor; 10 | 11 | constructor(requestConfigurator: RequestConfigurator, apiInteractor: ApiInteractor) { 12 | this.requestConfigurator = requestConfigurator; 13 | this.apiInteractor = apiInteractor; 14 | } 15 | 16 | async createIdentity(qonversionId: string, identityId: string): Promise { 17 | const request = this.requestConfigurator.configureCreateIdentityRequest(qonversionId, identityId); 18 | const response = await this.apiInteractor.execute(request); 19 | 20 | if (response.isSuccess) { 21 | return response.data.user_id; 22 | } 23 | 24 | const errorMessage = `Response code ${response.code}, message: ${response.message}`; 25 | throw new QonversionError(QonversionErrorCode.BackendError, errorMessage); 26 | } 27 | 28 | async obtainIdentity(identityId: string): Promise { 29 | const request = this.requestConfigurator.configureIdentityRequest(identityId); 30 | const response = await this.apiInteractor.execute(request); 31 | 32 | if (response.isSuccess) { 33 | return response.data.user_id; 34 | } 35 | 36 | if (response.code == HTTP_CODE_NOT_FOUND) { 37 | throw new QonversionError(QonversionErrorCode.IdentityNotFound, `Id: ${identityId}`); 38 | } 39 | 40 | const errorMessage = `Response code ${response.code}, message: ${response.message}`; 41 | throw new QonversionError(QonversionErrorCode.BackendError, errorMessage); 42 | } 43 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@qonversion/web-sdk", 3 | "title": "Qonversion Web SDK", 4 | "version": "1.1.1", 5 | "description": "Qonversion provides full in-app purchases infrastructure, so you do not need to build your own server for receipt validation. Implement in-app subscriptions, validate user receipts, check subscription status, and provide access to your app features and content using our Stripe wrapper, StoreKit wrapper and Google Play Billing wrapper.", 6 | "main": "sdk/build/index.js", 7 | "types": "sdk/build/index.d.ts", 8 | "files": [ 9 | "sdk/build" 10 | ], 11 | "scripts": { 12 | "build": "yarn tsc", 13 | "test": "jest ./sdk/src/__tests__", 14 | "integrationTests": "jest ./sdk/src/__integrationTests__", 15 | "lint": "eslint . --max-warnings=0" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/qonversion/web-sdk.git", 20 | "baseUrl": "https://github.com/qonversion/web-sdk" 21 | }, 22 | "homepage": "https://github.com/qonversion/web-sdk#readme", 23 | "keywords": [ 24 | "web", 25 | "qonversion", 26 | "Stripe", 27 | "Billing", 28 | "in-app", 29 | "Purchases" 30 | ], 31 | "author": { 32 | "name": "Qonversion", 33 | "email": "hi@qonversion.io" 34 | }, 35 | "license": "MIT", 36 | "licenseFilename": "LICENSE", 37 | "readmeFilename": "README.md", 38 | "devDependencies": { 39 | "@babel/core": "^7.18.2", 40 | "@babel/preset-env": "^7.18.2", 41 | "@babel/preset-typescript": "^7.17.12", 42 | "@types/jest": "^29.4.0", 43 | "@typescript-eslint/eslint-plugin": "^5.28.0", 44 | "@typescript-eslint/parser": "^5.28.0", 45 | "babel-jest": "^28.1.0", 46 | "dotenv": "^16.3.1", 47 | "eslint": "^8.17.0", 48 | "eslint-config-prettier": "^8.5.0", 49 | "jest": "^29.4.2", 50 | "ts-jest": "^29.1.0", 51 | "typescript": "^4.7.3" 52 | }, 53 | "dependencies": { 54 | "@types/uuid": "^8.3.4", 55 | "uuid": "^8.3.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /sdk/src/internal/userProperties/UserPropertiesService.ts: -------------------------------------------------------------------------------- 1 | import {UserPropertiesSendResponse, UserPropertiesService, UserPropertyData} from './types'; 2 | import {ApiInteractor, RequestConfigurator} from '../network'; 3 | import {QonversionError} from '../../exception/QonversionError'; 4 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 5 | 6 | export class UserPropertiesServiceImpl implements UserPropertiesService { 7 | private readonly requestConfigurator: RequestConfigurator; 8 | private readonly apiInteractor: ApiInteractor; 9 | 10 | constructor(requestConfigurator: RequestConfigurator, apiInteractor: ApiInteractor) { 11 | this.requestConfigurator = requestConfigurator; 12 | this.apiInteractor = apiInteractor; 13 | } 14 | 15 | async sendProperties(userId: string, properties: Record): Promise { 16 | const propertiesList: UserPropertyData[] = Object.keys(properties).map(key => ({ 17 | key, 18 | value: properties[key], 19 | })); 20 | 21 | const request = this.requestConfigurator.configureUserPropertiesSendRequest(userId, propertiesList); 22 | const response = await this.apiInteractor.execute(request); 23 | 24 | if (response.isSuccess) { 25 | return response.data; 26 | } 27 | 28 | const errorMessage = `Response code ${response.code}, message: ${response.message}`; 29 | throw new QonversionError(QonversionErrorCode.BackendError, errorMessage); 30 | } 31 | 32 | async getProperties(userId: string): Promise { 33 | const request = this.requestConfigurator.configureUserPropertiesGetRequest(userId); 34 | const response = await this.apiInteractor.execute(request); 35 | 36 | if (response.isSuccess) { 37 | return response.data; 38 | } 39 | 40 | const errorMessage = `Response code ${response.code}, message: ${response.message}`; 41 | throw new QonversionError(QonversionErrorCode.BackendError, errorMessage); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /sdk/src/internal/entitlements/EntitlementsController.ts: -------------------------------------------------------------------------------- 1 | import {EntitlementsController, EntitlementsService} from './types'; 2 | import {Entitlement} from '../../dto/Entitlement'; 3 | import {UserController, UserDataStorage} from '../user'; 4 | import {Logger} from '../logger'; 5 | import {QonversionError} from '../../exception/QonversionError'; 6 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 7 | 8 | export class EntitlementsControllerImpl implements EntitlementsController { 9 | private readonly userController: UserController; 10 | private readonly entitlementsService: EntitlementsService; 11 | private readonly userDataStorage: UserDataStorage; 12 | private readonly logger: Logger; 13 | 14 | constructor(userController: UserController, entitlementsService: EntitlementsService, userDataStorage: UserDataStorage, logger: Logger) { 15 | this.userController = userController; 16 | this.entitlementsService = entitlementsService; 17 | this.userDataStorage = userDataStorage; 18 | this.logger = logger; 19 | } 20 | 21 | async getEntitlements(): Promise { 22 | try { 23 | const userId = this.userDataStorage.requireOriginalUserId(); 24 | this.logger.verbose('Requesting entitlements', {userId}); 25 | const entitlements = await this.entitlementsService.getEntitlements(userId); 26 | this.logger.info('Successfully received entitlements', entitlements); 27 | return entitlements; 28 | } catch (error) { 29 | if (error instanceof QonversionError && error.code == QonversionErrorCode.UserNotFound) { 30 | try { 31 | this.logger.verbose('User is not registered. Creating new one'); 32 | await this.userController.createUser(); 33 | } catch (userCreationError) { 34 | this.logger.error('Failed to create new user while requesting entitlements', userCreationError); 35 | } 36 | return []; 37 | } else { 38 | this.logger.error('Failed to request entitlements', error); 39 | throw error; 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /sdk/src/internal/di/NetworkAssembly.ts: -------------------------------------------------------------------------------- 1 | import {MiscAssembly, NetworkAssembly, StorageAssembly} from './types'; 2 | import { 3 | ApiInteractorImpl, 4 | HeaderBuilderImpl, 5 | ApiInteractor, 6 | HeaderBuilder, 7 | NetworkClient, 8 | RequestConfigurator, 9 | NetworkClientImpl, 10 | RequestConfiguratorImpl, 11 | RetryPolicy, 12 | RetryPolicyExponential, 13 | RetryPolicyInfiniteExponential, 14 | } from '../network'; 15 | import {InternalConfig} from '../InternalConfig'; 16 | 17 | export class NetworkAssemblyImpl implements NetworkAssembly { 18 | private readonly internalConfig: InternalConfig; 19 | private readonly storageAssembly: StorageAssembly; 20 | private readonly miscAssembly: MiscAssembly; 21 | 22 | constructor(internalConfig: InternalConfig, storageAssembly: StorageAssembly, miscAssembly: MiscAssembly) { 23 | this.internalConfig = internalConfig; 24 | this.storageAssembly = storageAssembly; 25 | this.miscAssembly = miscAssembly; 26 | } 27 | 28 | networkClient(): NetworkClient { 29 | return new NetworkClientImpl(); 30 | } 31 | 32 | requestConfigurator(): RequestConfigurator { 33 | return new RequestConfiguratorImpl( 34 | this.headerBuilder(), 35 | this.internalConfig.networkConfig.apiUrl, 36 | this.internalConfig, 37 | this.storageAssembly.userDataProvider() 38 | ); 39 | } 40 | 41 | headerBuilder(): HeaderBuilder { 42 | return new HeaderBuilderImpl( 43 | this.internalConfig, 44 | this.internalConfig, 45 | this.storageAssembly.userDataProvider(), 46 | ); 47 | } 48 | 49 | exponentialApiInteractor(): ApiInteractor { 50 | return this.apiInteractor(new RetryPolicyExponential()); 51 | } 52 | 53 | infiniteExponentialApiInteractor(): ApiInteractor { 54 | return this.apiInteractor(new RetryPolicyInfiniteExponential()); 55 | } 56 | 57 | private apiInteractor(retryPolicy: RetryPolicy): ApiInteractor { 58 | return new ApiInteractorImpl( 59 | this.networkClient(), 60 | this.miscAssembly.exponentialDelayCalculator(), 61 | this.internalConfig, 62 | retryPolicy, 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /sdk/src/internal/di/types.ts: -------------------------------------------------------------------------------- 1 | import {Logger} from '../logger'; 2 | import {ApiInteractor, HeaderBuilder, NetworkClient, RequestConfigurator, RetryDelayCalculator} from '../network'; 3 | import { 4 | IdentityService, 5 | UserDataProvider, 6 | UserController, 7 | UserDataStorage, 8 | UserIdGenerator, 9 | UserService 10 | } from '../user'; 11 | import {LocalStorage} from '../common'; 12 | import {UserPropertiesController, UserPropertiesService, UserPropertiesStorage} from '../userProperties'; 13 | import {DelayedWorker} from '../utils/DelayedWorker'; 14 | import {EntitlementsController, EntitlementsService} from '../entitlements'; 15 | import {PurchasesController, PurchasesService} from '../purchases'; 16 | 17 | export type MiscAssembly = { 18 | logger: () => Logger; 19 | exponentialDelayCalculator: () => RetryDelayCalculator; 20 | delayedWorker: () => DelayedWorker; 21 | userIdGenerator: () => UserIdGenerator; 22 | }; 23 | 24 | export type NetworkAssembly = { 25 | networkClient: () => NetworkClient; 26 | requestConfigurator: () => RequestConfigurator; 27 | headerBuilder: () => HeaderBuilder; 28 | exponentialApiInteractor: () => ApiInteractor; 29 | infiniteExponentialApiInteractor: () => ApiInteractor; 30 | }; 31 | 32 | export type ServicesAssembly = { 33 | userPropertiesService: () => UserPropertiesService; 34 | userService: () => UserService; 35 | userServiceDecorator: () => UserService; 36 | identityService: () => IdentityService; 37 | entitlementsService: () => EntitlementsService; 38 | purchasesService: () => PurchasesService; 39 | }; 40 | 41 | export type ControllersAssembly = { 42 | userPropertiesController: () => UserPropertiesController; 43 | userController: () => UserController; 44 | entitlementsController: () => EntitlementsController; 45 | purchasesController: () => PurchasesController; 46 | }; 47 | 48 | export type StorageAssembly = { 49 | localStorage: () => LocalStorage; 50 | sentUserPropertiesStorage: () => UserPropertiesStorage; 51 | pendingUserPropertiesStorage: () => UserPropertiesStorage; 52 | userDataProvider: () => UserDataProvider; 53 | userDataStorage: () => UserDataStorage; 54 | }; 55 | -------------------------------------------------------------------------------- /sdk/src/__integrationTests__/aegis/EntitlementsService.test.ts: -------------------------------------------------------------------------------- 1 | import {AEGIS_URL, PRIVATE_TOKEN_FOR_TESTS} from '../constants'; 2 | import {executeGrantEntitlementsRequest} from '../apiV3Utils'; 3 | import {getCurrentTs, getDependencyAssembly} from '../utils'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | // @ts-ignore 7 | // noinspection JSConstantReassignment 8 | global.localStorage = { 9 | getItem: jest.fn(), 10 | }; 11 | 12 | describe('entitlements tests', function () { 13 | const dependenciesAssembly = getDependencyAssembly({apiUrl: AEGIS_URL}); 14 | 15 | const userService = dependenciesAssembly.userService(); 16 | const entitlementsService = dependenciesAssembly.entitlementsService(); 17 | 18 | describe('GET entitlements', function () { 19 | it('get entitlements for new user', async () => { 20 | // given 21 | const userId = 'testEntitlementUserId' + Date.now(); 22 | await userService.createUser(userId); 23 | 24 | // when 25 | const res = await entitlementsService.getEntitlements(userId); 26 | 27 | // then 28 | expect(res).toEqual([]); 29 | }); 30 | 31 | it('get entitlements for user with entitlements', async () => { 32 | // given 33 | const userId = 'testEntitlementUserId' + Date.now(); 34 | await userService.createUser(userId); 35 | const entitlementId = 'test_permission'; 36 | const expires = getCurrentTs() + 10000; 37 | const entitlementResponse = await executeGrantEntitlementsRequest(AEGIS_URL, PRIVATE_TOKEN_FOR_TESTS, userId, entitlementId, expires); 38 | const entitlement = await entitlementResponse.json(); 39 | 40 | // when 41 | const res = await entitlementsService.getEntitlements(userId); 42 | 43 | // then 44 | expect(res).toEqual([entitlement]); 45 | }); 46 | 47 | it('get entitlements for non-existent user', async () => { 48 | // given 49 | const userId = 'testNonExistentUserId' + Date.now(); 50 | 51 | // when 52 | const res = await entitlementsService.getEntitlements(userId); 53 | 54 | // then 55 | expect(res).toEqual([]); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /sdk/src/Qonversion.ts: -------------------------------------------------------------------------------- 1 | import {QonversionConfig, QonversionInstance} from './types'; 2 | import {QonversionError} from './exception/QonversionError'; 3 | import {QonversionErrorCode} from './exception/QonversionErrorCode'; 4 | import {QonversionInternal, InternalConfig} from './internal'; 5 | import {DependenciesAssemblyBuilder} from './internal/di/DependenciesAssembly'; 6 | 7 | class Qonversion { 8 | private static backingInstance: QonversionInstance | undefined = undefined 9 | 10 | private constructor() {} 11 | 12 | /** 13 | * Use this method to get a current initialized instance of the Qonversion SDK. 14 | * Please, use the method only after calling {@link Qonversion.initialize}. 15 | * Otherwise, calling the method will cause an exception. 16 | * 17 | * @return Current initialized instance of the Qonversion SDK. 18 | * @throws a {@link QonversionError} with {@link QonversionErrorCode.NotInitialized}. 19 | */ 20 | static getSharedInstance(): QonversionInstance { 21 | if (!Qonversion.backingInstance) { 22 | throw new QonversionError(QonversionErrorCode.NotInitialized); 23 | } 24 | return Qonversion.backingInstance; 25 | } 26 | 27 | /** 28 | * An entry point to use Qonversion SDK. Call to initialize Qonversion SDK with required and extra configs. 29 | * The function is the best way to set additional configs you need to use Qonversion SDK. 30 | * You still have an option to set a part of additional configs later via calling separated setters. 31 | * 32 | * @param config a config that contains key SDK settings. 33 | * Call {@link QonversionConfigBuilder.build} to configure and create a {@link QonversionConfig} instance. 34 | * @return Initialized instance of the Qonversion SDK. 35 | */ 36 | static initialize(config: QonversionConfig): QonversionInstance { 37 | const internalConfig = new InternalConfig(config); 38 | const dependenciesAssembly = new DependenciesAssemblyBuilder(internalConfig).build(); 39 | const instance = new QonversionInternal(internalConfig, dependenciesAssembly); 40 | Qonversion.backingInstance = instance; 41 | return instance; 42 | } 43 | } 44 | 45 | export default Qonversion; 46 | -------------------------------------------------------------------------------- /sdk/src/internal/di/ServicesAssembly.ts: -------------------------------------------------------------------------------- 1 | import {NetworkAssembly, ServicesAssembly} from './types'; 2 | import {UserPropertiesService, UserPropertiesServiceImpl} from '../userProperties'; 3 | import {IdentityService, IdentityServiceImpl, UserService, UserServiceDecorator, UserServiceImpl} from '../user'; 4 | import {EntitlementsService, EntitlementsServiceImpl} from '../entitlements'; 5 | import {PurchaseServiceImpl, PurchasesService} from '../purchases'; 6 | import {InternalConfig} from '../InternalConfig'; 7 | 8 | export class ServicesAssemblyImpl implements ServicesAssembly { 9 | private readonly internalConfig: InternalConfig; 10 | private readonly networkAssembly: NetworkAssembly; 11 | 12 | constructor(internalConfig: InternalConfig, networkAssembly: NetworkAssembly) { 13 | this.internalConfig = internalConfig; 14 | this.networkAssembly = networkAssembly; 15 | } 16 | 17 | userPropertiesService(): UserPropertiesService { 18 | return new UserPropertiesServiceImpl( 19 | this.networkAssembly.requestConfigurator(), 20 | this.networkAssembly.infiniteExponentialApiInteractor(), 21 | ); 22 | } 23 | 24 | userService(): UserService { 25 | return new UserServiceImpl( 26 | this.internalConfig, 27 | this.networkAssembly.requestConfigurator(), 28 | this.networkAssembly.exponentialApiInteractor(), 29 | ); 30 | } 31 | 32 | userServiceDecorator(): UserService { 33 | return new UserServiceDecorator( 34 | this.userService(), 35 | ); 36 | } 37 | 38 | identityService(): IdentityService { 39 | return new IdentityServiceImpl( 40 | this.networkAssembly.requestConfigurator(), 41 | this.networkAssembly.exponentialApiInteractor(), 42 | ); 43 | } 44 | 45 | entitlementsService(): EntitlementsService { 46 | return new EntitlementsServiceImpl( 47 | this.networkAssembly.requestConfigurator(), 48 | this.networkAssembly.exponentialApiInteractor(), 49 | ); 50 | } 51 | 52 | purchasesService(): PurchasesService { 53 | return new PurchaseServiceImpl( 54 | this.networkAssembly.requestConfigurator(), 55 | this.networkAssembly.exponentialApiInteractor(), 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # For a list of all available plugins, check out 9 | # 10 | # https://docs.fastlane.tools/plugins/available-plugins 11 | # 12 | 13 | # Uncomment the line if you want fastlane to automatically update itself 14 | # update_fastlane 15 | 16 | default_platform(:mac) 17 | 18 | def update_package_json(new_version) 19 | path = "../package.json" 20 | regex = /"version": ".*",/ 21 | result_value = "\"version\": \"#{new_version}\"," 22 | 23 | update_file(path, regex, result_value) 24 | end 25 | 26 | def update_aegis_url_in_tests(url) 27 | path = Dir['../**/__integrationTests__/constants.ts'].first 28 | 29 | regex = /AEGIS_URL = .*/ 30 | value = "AEGIS_URL = \"#{url}\"" 31 | 32 | update_file(path, regex, value) 33 | end 34 | 35 | def update_stripe_config_in_tests(product_id, subscription_id) 36 | path = Dir['../**/__integrationTests__/constants.ts'].first 37 | 38 | # Update STRIPE_PRODUCT_ID 39 | product_regex = /STRIPE_PRODUCT_ID = .*/ 40 | product_value = "STRIPE_PRODUCT_ID = \"#{product_id}\"" 41 | update_file(path, product_regex, product_value) 42 | 43 | # Update STRIPE_SUBSCRIPTION_ID 44 | subscription_regex = /STRIPE_SUBSCRIPTION_ID = .*/ 45 | subscription_value = "STRIPE_SUBSCRIPTION_ID = \"#{subscription_id}\"" 46 | update_file(path, subscription_regex, subscription_value) 47 | end 48 | 49 | def update_file(path, regex, result_value) 50 | file = File.read(path) 51 | new_content = file.gsub(regex, result_value) 52 | File.open(path, 'w') { |line| line.puts new_content } 53 | end 54 | 55 | platform :mac do 56 | lane :bump do |options| 57 | new_version = options[:version] 58 | 59 | update_package_json(new_version) 60 | end 61 | 62 | lane :setAegisUrl do |options| 63 | path = options[:url] 64 | 65 | update_aegis_url_in_tests(path) 66 | end 67 | 68 | lane :updateStripeSecrets do |options| 69 | product_id = options[:product_id] 70 | subscription_id = options[:subscription_id] 71 | 72 | update_stripe_config_in_tests(product_id, subscription_id) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /sdk/src/internal/user/UserService.ts: -------------------------------------------------------------------------------- 1 | import {UserApi, UserService} from './types'; 2 | import {User} from '../../dto/User'; 3 | import {ApiInteractor, RequestConfigurator} from '../network'; 4 | import {QonversionError} from '../../exception/QonversionError'; 5 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 6 | import {HTTP_CODE_NOT_FOUND} from '../network/constants'; 7 | import {camelCaseKeys} from '../utils/objectUtils'; 8 | import {PrimaryConfigProvider} from '../types'; 9 | 10 | export class UserServiceImpl implements UserService { 11 | private readonly primaryConfigProvider: PrimaryConfigProvider; 12 | private readonly requestConfigurator: RequestConfigurator; 13 | private readonly apiInteractor: ApiInteractor; 14 | 15 | constructor( 16 | primaryConfigProvider: PrimaryConfigProvider, 17 | requestConfigurator: RequestConfigurator, 18 | apiInteractor: ApiInteractor, 19 | ) { 20 | this.primaryConfigProvider = primaryConfigProvider; 21 | this.requestConfigurator = requestConfigurator; 22 | this.apiInteractor = apiInteractor; 23 | } 24 | 25 | async createUser(id: string): Promise { 26 | const environment = this.primaryConfigProvider.getPrimaryConfig().environment; 27 | const request = this.requestConfigurator.configureCreateUserRequest(id, environment); 28 | const response = await this.apiInteractor.execute(request); 29 | 30 | if (response.isSuccess) { 31 | return camelCaseKeys(response.data); 32 | } 33 | 34 | const errorMessage = `Response code ${response.code}, message: ${response.message}`; 35 | throw new QonversionError(QonversionErrorCode.BackendError, errorMessage); 36 | } 37 | 38 | async getUser(id: string): Promise { 39 | const request = this.requestConfigurator.configureUserRequest(id); 40 | const response = await this.apiInteractor.execute(request); 41 | 42 | if (response.isSuccess) { 43 | return camelCaseKeys(response.data); 44 | } 45 | 46 | if (response.code == HTTP_CODE_NOT_FOUND) { 47 | throw new QonversionError(QonversionErrorCode.UserNotFound, `Id: ${id}`); 48 | } 49 | 50 | const errorMessage = `Response code ${response.code}, message: ${response.message}`; 51 | throw new QonversionError(QonversionErrorCode.BackendError, errorMessage); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/network/HeaderBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import {ApiHeader, HeaderBuilderImpl, RequestHeaders} from '../../../internal/network'; 2 | import {EnvironmentProvider, PrimaryConfigProvider} from '../../../internal'; 3 | import {Environment} from '../../../index'; 4 | import {PrimaryConfig} from '../../../types'; 5 | import {UserDataProvider} from '../../../internal/user'; 6 | 7 | const testProjectKey = 'test project key'; 8 | const testSdkVersion = '500.1.1'; 9 | const testUserId = 'test uid'; 10 | 11 | let currentPrimaryConfig: PrimaryConfig = { 12 | // @ts-ignore 13 | environment: undefined, 14 | launchMode: undefined, 15 | projectKey: testProjectKey, 16 | sdkVersion: testSdkVersion, 17 | }; 18 | const primaryConfigProvider: PrimaryConfigProvider = { 19 | getPrimaryConfig(): PrimaryConfig { 20 | return currentPrimaryConfig; 21 | } 22 | }; 23 | 24 | let currentEnvironment = Environment.Production; 25 | const environmentProvider: EnvironmentProvider = { 26 | getEnvironment(): Environment { 27 | return currentEnvironment; 28 | }, 29 | 30 | isSandbox(): boolean { 31 | return currentEnvironment == Environment.Sandbox; 32 | } 33 | }; 34 | 35 | // @ts-ignore 36 | const userDataProvider: UserDataProvider = { 37 | getOriginalUserId: () => testUserId, 38 | }; 39 | 40 | const headerBuilder = new HeaderBuilderImpl(primaryConfigProvider, environmentProvider, userDataProvider); 41 | 42 | describe('buildCommonHeaders tests', () => { 43 | const commonExpectedHeaders: RequestHeaders = { 44 | [ApiHeader.Authorization]: 'Bearer ' + testProjectKey, 45 | [ApiHeader.Platform]: 'web', 46 | [ApiHeader.PlatformVersion]: testSdkVersion, 47 | [ApiHeader.UserID]: testUserId, 48 | }; 49 | 50 | test('common headers test', () => { 51 | // given 52 | 53 | // when 54 | const res = headerBuilder.buildCommonHeaders(); 55 | 56 | // then 57 | expect(res).toStrictEqual(commonExpectedHeaders); 58 | }); 59 | 60 | test('debug mode', () => { 61 | // given 62 | currentEnvironment = Environment.Sandbox; 63 | const expectedHeaders: RequestHeaders = { 64 | ...commonExpectedHeaders, 65 | [ApiHeader.Authorization]: 'Bearer test_' + testProjectKey, 66 | }; 67 | 68 | // when 69 | const res = headerBuilder.buildCommonHeaders(); 70 | 71 | // then 72 | expect(res).toStrictEqual(expectedHeaders); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /sdk/src/__integrationTests__/apiV3/EntitlementsService.test.ts: -------------------------------------------------------------------------------- 1 | import {PRIVATE_TOKEN_FOR_TESTS} from '../constants'; 2 | import {executeGrantEntitlementsRequest} from '../apiV3Utils'; 3 | import {expectQonversionErrorAsync, getCurrentTs, getDependencyAssembly} from '../utils'; 4 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 5 | import {API_URL} from '../../internal/network'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 8 | // @ts-ignore 9 | // noinspection JSConstantReassignment 10 | global.localStorage = { 11 | getItem: jest.fn(), 12 | }; 13 | 14 | describe('entitlements tests', function () { 15 | const dependenciesAssembly = getDependencyAssembly(); 16 | 17 | const userService = dependenciesAssembly.userService(); 18 | const entitlementsService = dependenciesAssembly.entitlementsService(); 19 | 20 | describe('GET entitlements', function () { 21 | it('get entitlements for new user', async () => { 22 | // given 23 | const userId = 'testEntitlementUserId' + Date.now(); 24 | await userService.createUser(userId); 25 | 26 | // when 27 | const res = await entitlementsService.getEntitlements(userId); 28 | 29 | // then 30 | expect(res).toEqual([]); 31 | }); 32 | 33 | it('get entitlements for user with entitlements', async () => { 34 | // given 35 | const userId = 'testEntitlementUserId' + Date.now(); 36 | await userService.createUser(userId); 37 | const entitlementId = 'test_permission'; 38 | const expires = getCurrentTs() + 10000; 39 | const entitlementResponse = await executeGrantEntitlementsRequest(API_URL, PRIVATE_TOKEN_FOR_TESTS, userId, entitlementId, expires); 40 | const entitlement = await entitlementResponse.json(); 41 | 42 | // when 43 | const res = await entitlementsService.getEntitlements(userId); 44 | 45 | // then 46 | expect(res).toEqual([entitlement]); 47 | }); 48 | 49 | it('get entitlements for non-existent user', async () => { 50 | // given 51 | const userId = 'testNonExistentUserId' + Date.now(); 52 | 53 | // when and then 54 | await expectQonversionErrorAsync( 55 | QonversionErrorCode.UserNotFound, 56 | 'Qonversion user not found. User id: ' + userId, 57 | async () => { 58 | await entitlementsService.getEntitlements(userId); 59 | }, 60 | ); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /.github/workflows/pr-checks.yml: -------------------------------------------------------------------------------- 1 | name: PR checks 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | defaults: 11 | run: 12 | working-directory: ./sdk 13 | steps: 14 | - name: Checkout commit 15 | uses: actions/checkout@v4 16 | 17 | - name: Use Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '20.x' 21 | 22 | - name: Cache NPM # leverage npm cache on repeated workflow runs if package.json didn't change 23 | uses: actions/cache@v4 24 | with: 25 | path: ~/.npm 26 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: | 28 | ${{ runner.os }}-node- 29 | - name: Install Dependencies 30 | run: yarn 31 | 32 | - name: Run linter 33 | run: yarn lint 34 | 35 | typecheck: 36 | runs-on: ubuntu-latest 37 | defaults: 38 | run: 39 | working-directory: ./sdk 40 | steps: 41 | - name: Checkout commit 42 | uses: actions/checkout@v4 43 | 44 | - name: Use Node.js 45 | uses: actions/setup-node@v4 46 | with: 47 | node-version: '20.x' 48 | 49 | - name: Cache NPM # leverage npm cache on repeated workflow runs if package.json didn't change 50 | uses: actions/cache@v4 51 | with: 52 | path: ~/.npm 53 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 54 | restore-keys: | 55 | ${{ runner.os }}-node- 56 | - name: Install Dependencies 57 | run: yarn 58 | 59 | - name: Run tsc 60 | run: yarn build 61 | 62 | test: 63 | runs-on: ubuntu-latest 64 | defaults: 65 | run: 66 | working-directory: ./sdk 67 | steps: 68 | - name: Checkout commit 69 | uses: actions/checkout@v4 70 | 71 | - name: Use Node.js 72 | uses: actions/setup-node@v4 73 | with: 74 | node-version: '20.x' 75 | 76 | - name: Cache NPM 77 | uses: actions/cache@v4 78 | with: 79 | path: ~/.npm 80 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 81 | restore-keys: | 82 | ${{ runner.os }}-node- 83 | - name: Install Dependencies 84 | run: yarn 85 | 86 | - name: Run tests 87 | run: yarn test 88 | -------------------------------------------------------------------------------- /sdk/src/internal/di/ControllersAssembly.ts: -------------------------------------------------------------------------------- 1 | import {ControllersAssembly, MiscAssembly, ServicesAssembly, StorageAssembly} from './types'; 2 | import {UserPropertiesController, UserPropertiesControllerImpl} from '../userProperties'; 3 | import {UserController, UserControllerImpl} from '../user'; 4 | import {EntitlementsController, EntitlementsControllerImpl} from '../entitlements'; 5 | import {PurchasesController, PurchasesControllerImpl} from '../purchases'; 6 | 7 | export class ControllersAssemblyImpl implements ControllersAssembly { 8 | private readonly miscAssembly: MiscAssembly; 9 | private readonly storageAssembly: StorageAssembly; 10 | private readonly servicesAssembly: ServicesAssembly; 11 | 12 | private sharedUserController: UserController | undefined; 13 | 14 | constructor(miscAssembly: MiscAssembly, storageAssembly: StorageAssembly, servicesAssembly: ServicesAssembly) { 15 | this.miscAssembly = miscAssembly; 16 | this.storageAssembly = storageAssembly; 17 | this.servicesAssembly = servicesAssembly; 18 | } 19 | 20 | userPropertiesController(): UserPropertiesController { 21 | return new UserPropertiesControllerImpl( 22 | this.storageAssembly.pendingUserPropertiesStorage(), 23 | this.storageAssembly.sentUserPropertiesStorage(), 24 | this.servicesAssembly.userPropertiesService(), 25 | this.storageAssembly.userDataStorage(), 26 | this.miscAssembly.delayedWorker(), 27 | this.miscAssembly.logger(), 28 | this.userController(), 29 | ); 30 | } 31 | 32 | userController(): UserController { 33 | if (this.sharedUserController) { 34 | return this.sharedUserController; 35 | } 36 | 37 | this.sharedUserController = new UserControllerImpl( 38 | this.servicesAssembly.userServiceDecorator(), 39 | this.servicesAssembly.identityService(), 40 | this.storageAssembly.userDataStorage(), 41 | this.miscAssembly.userIdGenerator(), 42 | this.miscAssembly.logger(), 43 | ); 44 | return this.sharedUserController; 45 | } 46 | 47 | entitlementsController(): EntitlementsController { 48 | return new EntitlementsControllerImpl( 49 | this.userController(), 50 | this.servicesAssembly.entitlementsService(), 51 | this.storageAssembly.userDataStorage(), 52 | this.miscAssembly.logger(), 53 | ); 54 | } 55 | 56 | purchasesController(): PurchasesController { 57 | return new PurchasesControllerImpl( 58 | this.servicesAssembly.purchasesService(), 59 | this.storageAssembly.userDataStorage(), 60 | this.miscAssembly.logger(), 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /sdk/src/__integrationTests__/apiV3Utils.ts: -------------------------------------------------------------------------------- 1 | import {ApiHeader} from '../internal/network'; 2 | 3 | export const executeGrantEntitlementsRequest = async ( 4 | apiUrl: string, 5 | accessToken: string, 6 | userId: string, 7 | entitlementId: string, 8 | expires: number, 9 | ): Promise => { 10 | return await fetch(encodeURI(`${apiUrl}/v3/users/${userId}/entitlements`), { 11 | method: 'POST', 12 | headers: { 13 | [ApiHeader.Authorization]: 'Bearer ' + accessToken, 14 | [ApiHeader.ContentType]: 'application/json', 15 | [ApiHeader.Accept]: 'application/json', 16 | }, 17 | body: JSON.stringify({ 18 | id: entitlementId, 19 | expires, 20 | }), 21 | }); 22 | }; 23 | 24 | export const executeRevokeEntitlementsRequest = async ( 25 | apiUrl: string, 26 | accessToken: string, 27 | userId: string, 28 | entitlementId: string, 29 | ): Promise => { 30 | return await fetch(encodeURI(`${apiUrl}/v3/users/${userId}/entitlements/${entitlementId}`), { 31 | method: 'DELETE', 32 | headers: { 33 | [ApiHeader.Authorization]: 'Bearer ' + accessToken, 34 | [ApiHeader.ContentType]: 'application/json', 35 | [ApiHeader.Accept]: 'application/json', 36 | }, 37 | }); 38 | }; 39 | 40 | export const executeGetScreenByContextKeyRequest = async ( 41 | apiUrl: string, 42 | accessToken: string, 43 | contextKey: string, 44 | ): Promise => { 45 | return await fetch(encodeURI(`${apiUrl}/v3/contexts/${contextKey}/screens`), { 46 | method: 'GET', 47 | headers: { 48 | [ApiHeader.Authorization]: 'Bearer ' + accessToken, 49 | [ApiHeader.ContentType]: 'application/json', 50 | [ApiHeader.Accept]: 'application/json', 51 | }, 52 | }); 53 | }; 54 | 55 | export const executeGetScreenByIdRequest = async ( 56 | apiUrl: string, 57 | accessToken: string, 58 | screenId: string, 59 | ): Promise => { 60 | return await fetch(encodeURI(`${apiUrl}/v3/screens/${screenId}`), { 61 | method: 'GET', 62 | headers: { 63 | [ApiHeader.Authorization]: 'Bearer ' + accessToken, 64 | [ApiHeader.ContentType]: 'application/json', 65 | [ApiHeader.Accept]: 'application/json', 66 | }, 67 | }); 68 | }; 69 | 70 | export const executePreloadScreensRequest = async ( 71 | apiUrl: string, 72 | accessToken: string, 73 | ): Promise => { 74 | return await fetch(encodeURI(`${apiUrl}/v3/screens?preload=true`), { 75 | method: 'GET', 76 | headers: { 77 | [ApiHeader.Authorization]: 'Bearer ' + accessToken, 78 | [ApiHeader.ContentType]: 'application/json', 79 | [ApiHeader.Accept]: 'application/json', 80 | }, 81 | }); 82 | }; 83 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/network/NetworkClient.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiHeader, 3 | NetworkClientImpl, 4 | NetworkRequest, 5 | RawNetworkResponse, 6 | RequestHeaders, 7 | RequestType 8 | } from '../../../internal/network'; 9 | 10 | const networkClient = new NetworkClientImpl(); 11 | 12 | const testUrl = 'test url'; 13 | const testHeaders = {a: 'a', b: 'b'}; 14 | const testBody = {c: 'c', d: 'd'}; 15 | const testCode = 212; 16 | const testPayload = {data: {someField: 'value'}}; 17 | 18 | describe('execute test', () => { 19 | const expResult: RawNetworkResponse = { 20 | code: testCode, 21 | payload: testPayload, 22 | }; 23 | 24 | const expHeaders: RequestHeaders = { 25 | ...testHeaders, 26 | [ApiHeader.ContentType]: 'application/json', 27 | [ApiHeader.Accept]: 'application/json', 28 | }; 29 | 30 | // @ts-ignore 31 | const mockFetch = jest.fn(() => 32 | Promise.resolve({ 33 | status: testCode, 34 | json: () => Promise.resolve(testPayload), 35 | }) 36 | ); 37 | 38 | const savedFetch = global.fetch; 39 | 40 | beforeAll(() => { 41 | Object.defineProperty(global, 'fetch', { 42 | writable: true, 43 | }); 44 | // @ts-ignore 45 | global.fetch = mockFetch; 46 | }); 47 | 48 | afterAll(() => { 49 | global.fetch = savedFetch; 50 | }); 51 | 52 | beforeEach(() => { 53 | mockFetch.mockClear(); 54 | }); 55 | 56 | test('usual execute', async () => { 57 | // given 58 | const request: NetworkRequest = { 59 | body: testBody, 60 | headers: testHeaders, 61 | type: RequestType.PUT, 62 | url: testUrl 63 | }; 64 | 65 | const expRequest: RequestInit = { 66 | method: request.type, 67 | headers: expHeaders, 68 | body: JSON.stringify(testBody), 69 | }; 70 | 71 | // when 72 | const result = await networkClient.execute(request); 73 | 74 | // then 75 | expect(result).toStrictEqual(expResult); 76 | expect(fetch).toBeCalledWith(testUrl, expRequest); 77 | }); 78 | 79 | test('execute without body', async () => { 80 | // given 81 | const request: NetworkRequest = { 82 | headers: testHeaders, 83 | type: RequestType.GET, 84 | url: testUrl 85 | }; 86 | 87 | const expRequest: RequestInit = { 88 | method: request.type, 89 | headers: expHeaders, 90 | }; 91 | 92 | // when 93 | const result = await networkClient.execute(request); 94 | 95 | // then 96 | expect(result).toStrictEqual(expResult); 97 | expect(fetch).toBeCalledWith(testUrl, expRequest); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /sdk/src/dto/UserProperties.ts: -------------------------------------------------------------------------------- 1 | import {UserProperty} from './UserProperty'; 2 | import {UserPropertyKey} from './UserPropertyKey'; 3 | 4 | export class UserProperties { 5 | /** 6 | * List of all user properties. 7 | */ 8 | properties: UserProperty[]; 9 | 10 | /** 11 | * List of user properties, set for the Qonversion defined keys. 12 | * This is a subset of all {@link properties} list. 13 | * See {@link QonversionInstance.setUserProperty}. 14 | */ 15 | definedProperties: UserProperty[]; 16 | 17 | /** 18 | * List of user properties, set for custom keys. 19 | * This is a subset of all {@link properties} list. 20 | * See {@link QonversionInstance.setCustomUserProperty}. 21 | */ 22 | customProperties: UserProperty[]; 23 | 24 | /** 25 | * Map of all user properties. 26 | * This is a flattened version of the {@link properties} list as a key-value map. 27 | */ 28 | flatPropertiesMap: Map; 29 | 30 | /** 31 | * Map of user properties, set for the Qonversion defined keys. 32 | * This is a flattened version of the {@link definedProperties} list as a key-value map. 33 | * See {@link QonversionInstance.setUserProperty}. 34 | */ 35 | flatDefinedPropertiesMap: Map; 36 | 37 | /** 38 | * Map of user properties, set for custom keys. 39 | * This is a flattened version of the {@link customProperties} list as a key-value map. 40 | * See {@link QonversionInstance.setCustomUserProperty}. 41 | */ 42 | flatCustomPropertiesMap: Map; 43 | 44 | constructor(properties: UserProperty[]) { 45 | this.properties = properties; 46 | this.definedProperties = properties.filter(property => property.definedKey !== UserPropertyKey.Custom); 47 | this.customProperties = properties.filter(property => property.definedKey === UserPropertyKey.Custom); 48 | 49 | this.flatPropertiesMap = new Map(); 50 | this.flatDefinedPropertiesMap = new Map(); 51 | this.flatCustomPropertiesMap = new Map(); 52 | properties.forEach(property => { 53 | this.flatPropertiesMap.set(property.key, property.value); 54 | if (property.definedKey == UserPropertyKey.Custom) { 55 | this.flatCustomPropertiesMap.set(property.key, property.value); 56 | } else { 57 | this.flatDefinedPropertiesMap.set(property.definedKey, property.value); 58 | } 59 | }); 60 | } 61 | 62 | /** 63 | * Searches for a property with the given property {@link key} in all properties list. 64 | */ 65 | getProperty(key: string): UserProperty | undefined { 66 | return this.properties.find(userProperty => userProperty.key == key); 67 | } 68 | 69 | /** 70 | * Searches for a property with the given Qonversion defined property {@link key} 71 | * in defined properties list. 72 | */ 73 | getDefinedProperty(key: UserPropertyKey): UserProperty | undefined { 74 | return this.definedProperties.find(userProperty => userProperty.definedKey == key); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/purchases/PurchasesController.test.ts: -------------------------------------------------------------------------------- 1 | import {UserDataStorage} from '../../../internal/user'; 2 | import {Logger} from '../../../internal/logger'; 3 | import {PurchaseCoreData, StripeStoreData, UserPurchase} from '../../../index'; 4 | import {PurchasesController, PurchasesService, PurchasesControllerImpl} from '../../../internal/purchases'; 5 | 6 | let purchasesService: PurchasesService; 7 | let userDataStorage: UserDataStorage; 8 | let logger: Logger; 9 | let purchasesController: PurchasesController; 10 | 11 | const testUserId = 'test user id'; 12 | const testUserPurchase: UserPurchase = { 13 | currency: 'USD', 14 | price: '10', 15 | purchased: 3243523432, 16 | stripeStoreData: { 17 | productId: 'test product id', 18 | subscriptionId: 'test subscription id' 19 | }, 20 | userId: testUserId, 21 | }; 22 | 23 | const testStripePurchaseData: PurchaseCoreData & StripeStoreData = { 24 | currency: 'USD', 25 | price: '10', 26 | productId: 'test product id', 27 | purchased: 3243523432, 28 | subscriptionId: 'test subscription id' 29 | }; 30 | 31 | beforeEach(() => { 32 | // @ts-ignore 33 | purchasesService = {}; 34 | // @ts-ignore 35 | userDataStorage = {}; 36 | // @ts-ignore 37 | logger = { 38 | verbose: jest.fn(), 39 | info: jest.fn(), 40 | error: jest.fn(), 41 | }; 42 | purchasesController = new PurchasesControllerImpl(purchasesService, userDataStorage, logger); 43 | }); 44 | 45 | describe('sendStripePurchase tests', () => { 46 | test('successfully sent', async () => { 47 | // given 48 | userDataStorage.requireOriginalUserId = jest.fn(() => testUserId); 49 | purchasesService.sendStripePurchase = jest.fn(async () => testUserPurchase); 50 | 51 | // when 52 | const res = await purchasesController.sendStripePurchase(testStripePurchaseData); 53 | 54 | // then 55 | expect(res).toStrictEqual(testUserPurchase); 56 | expect(userDataStorage.requireOriginalUserId).toBeCalled(); 57 | expect(purchasesService.sendStripePurchase).toBeCalledWith(testUserId, testStripePurchaseData); 58 | expect(logger.info).toBeCalledWith('Successfully sent the Stripe purchase', testUserPurchase); 59 | expect(logger.verbose).toBeCalledWith('Sending Stripe purchase', {userId: testUserId, data: testStripePurchaseData}); 60 | }); 61 | 62 | test('unknown error while sending purchase', async () => { 63 | // given 64 | userDataStorage.requireOriginalUserId = jest.fn(() => testUserId); 65 | const unknownError = new Error('unknown error'); 66 | purchasesService.sendStripePurchase = jest.fn(async () => {throw unknownError}); 67 | 68 | // when and then 69 | await expect(purchasesController.sendStripePurchase(testStripePurchaseData)).rejects.toThrow(unknownError); 70 | expect(purchasesService.sendStripePurchase).toBeCalledWith(testUserId, testStripePurchaseData); 71 | expect(logger.error).toBeCalledWith('Failed to send the Stripe purchase', unknownError); 72 | expect(logger.verbose).toBeCalledWith('Sending Stripe purchase', {userId: testUserId, data: testStripePurchaseData}); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /sdk/src/internal/network/types.ts: -------------------------------------------------------------------------------- 1 | import {RetryPolicy} from './RetryPolicy'; 2 | import {PurchaseCoreData, StripeStoreData} from '../../dto/Purchase'; 3 | import {Environment} from '../../dto/Environment'; 4 | import {UserPropertyData} from '../userProperties'; 5 | 6 | export enum ApiHeader { 7 | Accept = "Accept", 8 | ContentType = "Content-Type", 9 | Authorization = "Authorization", 10 | Locale = "User-Locale", 11 | Source = "Source", 12 | SourceVersion = "Source-Version", 13 | Platform = "Platform", 14 | PlatformVersion = "Platform-Version", 15 | UserID = "User-Id", 16 | } 17 | 18 | export enum ApiEndpoint { 19 | Users = "v3/users", 20 | Identity = "v3/identities", 21 | Properties = "properties", 22 | } 23 | 24 | export enum RequestType { 25 | POST = "POST", 26 | GET = "GET", 27 | DELETE = "DELETE", 28 | PUT = "PUT", 29 | } 30 | 31 | export type RequestHeaders = Record; 32 | export type RequestBody = Record | Array; 33 | 34 | export type NetworkRequest = { 35 | url: string; 36 | type: RequestType; 37 | headers: RequestHeaders; 38 | body?: RequestBody; 39 | }; 40 | 41 | export type NetworkResponseBase = { 42 | code: number; 43 | }; 44 | 45 | export type RawNetworkResponse = NetworkResponseBase & { 46 | // eslint-disable-next-line 47 | payload: any; 48 | }; 49 | 50 | export type ApiResponseError = NetworkResponseBase & { 51 | message: string; 52 | type?: string; 53 | apiCode?: string; 54 | isSuccess: false; 55 | }; 56 | 57 | export type ApiResponseSuccess = NetworkResponseBase & { 58 | data: T; 59 | isSuccess: true; 60 | }; 61 | 62 | export type ApiError = { 63 | message: string; 64 | type?: string; 65 | code?: string; 66 | }; 67 | 68 | export type NetworkRetryConfig = { 69 | shouldRetry: boolean; 70 | attemptIndex: number; 71 | delay: number; 72 | }; 73 | 74 | export type NetworkClient = { 75 | execute: (request: NetworkRequest) => Promise; 76 | }; 77 | 78 | export type ApiInteractor = { 79 | execute: (request: NetworkRequest, retryPolicy?: RetryPolicy) => Promise | ApiResponseError>; 80 | }; 81 | 82 | export type RequestConfigurator = { 83 | configureUserRequest: (id: string) => NetworkRequest; 84 | 85 | configureCreateUserRequest: (id: string, environment: Environment) => NetworkRequest; 86 | 87 | configureUserPropertiesSendRequest: (userId: string, properties: UserPropertyData[]) => NetworkRequest; 88 | 89 | configureUserPropertiesGetRequest: (userId: string) => NetworkRequest; 90 | 91 | configureIdentityRequest: (identityId: string) => NetworkRequest; 92 | 93 | configureCreateIdentityRequest: (qonversionId: string, identityId: string) => NetworkRequest; 94 | 95 | configureEntitlementsRequest: (userId: string) => NetworkRequest; 96 | 97 | configureStripePurchaseRequest: (userId: string, data: PurchaseCoreData & StripeStoreData) => NetworkRequest; 98 | }; 99 | 100 | export type HeaderBuilder = { 101 | buildCommonHeaders: () => RequestHeaders; 102 | }; 103 | -------------------------------------------------------------------------------- /sdk/src/__integrationTests__/aegis/UserService.test.ts: -------------------------------------------------------------------------------- 1 | import {User} from '../../dto/User'; 2 | import {Environment} from '../../dto/Environment'; 3 | import {getDependencyAssembly} from '../utils'; 4 | import {AEGIS_URL} from '../constants'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 | // @ts-ignore 8 | // noinspection JSConstantReassignment 9 | global.localStorage = { 10 | getItem: jest.fn(), 11 | }; 12 | 13 | describe('users tests', function () { 14 | const dependenciesAssembly = getDependencyAssembly({apiUrl: AEGIS_URL}); 15 | 16 | const userService = dependenciesAssembly.userService(); 17 | 18 | describe('POST users', function () { 19 | it('create production user', async () => { 20 | // given 21 | const testUserId = 'testProd' + Date.now(); 22 | const expectedUser: User = { 23 | created: 0, 24 | environment: Environment.Production, 25 | id: testUserId, 26 | identityId: undefined, 27 | }; 28 | 29 | // when 30 | const res = await userService.createUser(testUserId); 31 | 32 | // then 33 | expect(res).toEqual(expectedUser); 34 | }); 35 | 36 | it('create existing user', async () => { 37 | // given 38 | const testUserId = 'testExistingUser' + Date.now(); 39 | await userService.createUser(testUserId); 40 | const expectedUser: User = { 41 | created: 0, 42 | environment: Environment.Production, 43 | id: testUserId, 44 | identityId: undefined, 45 | }; 46 | 47 | // when 48 | const res = await userService.createUser(testUserId); 49 | 50 | // then 51 | expect(res).toEqual(expectedUser); 52 | }); 53 | 54 | it('create sandbox user', async () => { 55 | // given 56 | const dependenciesAssembly = getDependencyAssembly({ 57 | environment: Environment.Sandbox, 58 | apiUrl: AEGIS_URL, 59 | }); 60 | 61 | const userService = dependenciesAssembly.userService(); 62 | 63 | const testUserId = 'testSandbox' + Date.now(); 64 | const expectedUser: User = { 65 | created: 0, 66 | environment: Environment.Sandbox, 67 | id: testUserId, 68 | identityId: undefined, 69 | }; 70 | 71 | // when 72 | const res = await userService.createUser(testUserId); 73 | 74 | // then 75 | expect(res).toEqual(expectedUser); 76 | }); 77 | }); 78 | 79 | describe('GET users', function () { 80 | it('get existing user', async () => { 81 | // given 82 | const testUserId = 'testGet' + Date.now(); 83 | const expUser = await userService.createUser(testUserId); 84 | 85 | // when 86 | const res = await userService.getUser(testUserId); 87 | 88 | // then 89 | expect(res).toEqual(expUser); 90 | }); 91 | 92 | it('get non-existent user', async () => { 93 | // given 94 | const nonExistentUserId = 'testNonExistent' + Date.now(); 95 | const expectedUser: User = { 96 | created: 0, 97 | environment: Environment.Production, 98 | id: nonExistentUserId, 99 | identityId: undefined, 100 | }; 101 | 102 | // when 103 | const res = await userService.getUser(nonExistentUserId); 104 | 105 | // then 106 | expect(res).toEqual(expectedUser); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /sdk/src/QonversionConfigBuilder.ts: -------------------------------------------------------------------------------- 1 | import {QonversionError} from './exception/QonversionError'; 2 | import {QonversionErrorCode} from './exception/QonversionErrorCode'; 3 | import {Environment} from './dto/Environment'; 4 | import {LogLevel} from './dto/LogLevel'; 5 | import {LoggerConfig, NetworkConfig, PrimaryConfig, QonversionConfig} from './types'; 6 | import {API_URL} from './internal/network'; 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-var-requires 9 | const packageJson = require('../../package.json'); 10 | const DEFAULT_LOG_TAG = "Qonversion"; 11 | 12 | /** 13 | * The builder of Qonversion configuration instance. 14 | * 15 | * This class contains a variety of methods to customize the Qonversion SDK behavior. 16 | * You can call them sequentially and call {@link build} finally to get the configuration instance. 17 | */ 18 | export class QonversionConfigBuilder { 19 | private readonly projectKey: string; 20 | private environment = Environment.Production; 21 | private logLevel = LogLevel.Info; 22 | private logTag = DEFAULT_LOG_TAG; 23 | 24 | /** 25 | * Creates an instance of a builder 26 | * @param projectKey your Project Key from Qonversion Dashboard 27 | */ 28 | constructor(projectKey: string) { 29 | this.projectKey = projectKey; 30 | }; 31 | 32 | /** 33 | * Set current application {@link Environment}. Used to distinguish sandbox and production users. 34 | * 35 | * @param environment current environment. 36 | * @return builder instance for chain calls. 37 | */ 38 | setEnvironment(environment: Environment): QonversionConfigBuilder { 39 | this.environment = environment; 40 | return this; 41 | }; 42 | 43 | /** 44 | * Define the level of the logs that the SDK prints. 45 | * The more strict the level is, the fewer logs will be written. 46 | * For example, setting the log level as Warning will disable all info and verbose logs. 47 | * 48 | * @param logLevel the desired allowed log level. 49 | * @return builder instance for chain calls. 50 | */ 51 | setLogLevel(logLevel: LogLevel): QonversionConfigBuilder { 52 | this.logLevel = logLevel; 53 | return this; 54 | }; 55 | 56 | /** 57 | * Define the log tag that the Qonversion SDK will print with every log message. 58 | * For example, you can use it to filter the Qonversion SDK logs and your app own logs together. 59 | * 60 | * @param logTag the desired log tag. 61 | * @return builder instance for chain calls. 62 | */ 63 | setLogTag(logTag: string): QonversionConfigBuilder { 64 | this.logTag = logTag; 65 | return this; 66 | }; 67 | 68 | /** 69 | * Generate {@link QonversionConfig} instance with all the provided configurations. 70 | * 71 | * @throws a {@link QonversionError} if unacceptable configuration was provided. 72 | * @return the complete {@link QonversionConfig} instance. 73 | */ 74 | build(): QonversionConfig { 75 | if (!this.projectKey) { 76 | throw new QonversionError(QonversionErrorCode.ConfigPreparation, "Project key is empty"); 77 | } 78 | 79 | const primaryConfig: PrimaryConfig = { 80 | projectKey: this.projectKey, 81 | environment: this.environment, 82 | sdkVersion: packageJson.version, 83 | }; 84 | 85 | const loggerConfig: LoggerConfig = { 86 | logLevel: this.logLevel, 87 | logTag: this.logTag, 88 | }; 89 | 90 | const networkConfig: NetworkConfig = { 91 | canSendRequests: true, 92 | apiUrl: API_URL, 93 | }; 94 | 95 | return { 96 | primaryConfig, 97 | loggerConfig, 98 | networkConfig, 99 | }; 100 | }; 101 | } 102 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/purchases/PurchasesService.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiInteractor, 3 | RequestConfigurator, 4 | NetworkRequest, 5 | ApiResponseError, 6 | ApiResponseSuccess 7 | } from '../../../internal/network'; 8 | import { 9 | PurchaseCoreData, 10 | QonversionError, 11 | QonversionErrorCode, 12 | StripeStoreData, 13 | UserPurchase 14 | } from '../../../index'; 15 | import {PurchaseServiceImpl, PurchasesService, UserPurchaseApi} from '../../../internal/purchases'; 16 | 17 | let requestConfigurator: RequestConfigurator; 18 | let apiInteractor: ApiInteractor; 19 | let purchasesService: PurchasesService; 20 | const testUserId = 'test user id'; 21 | 22 | const apiPurchase: UserPurchaseApi = { 23 | currency: 'USD', 24 | price: '10', 25 | purchased: 3243523432, 26 | userId: testUserId, 27 | stripe_store_data: { 28 | product_id: 'test product id', 29 | subscription_id: 'test subscription id' 30 | }, 31 | }; 32 | 33 | const testSuccessfulResponse: ApiResponseSuccess = { 34 | code: 200, 35 | data: apiPurchase, 36 | isSuccess: true 37 | }; 38 | const testErrorCode = 500; 39 | const testErrorMessage = 'Test error message'; 40 | const testErrorResponse: ApiResponseError = { 41 | code: testErrorCode, 42 | apiCode: '', 43 | message: testErrorMessage, 44 | type: '', 45 | isSuccess: false, 46 | }; 47 | const expRes: UserPurchase = { 48 | currency: 'USD', 49 | price: '10', 50 | purchased: 3243523432, 51 | userId: testUserId, 52 | stripeStoreData: { 53 | productId: 'test product id', 54 | subscriptionId: 'test subscription id' 55 | }, 56 | }; 57 | 58 | const testStripePurchaseRequest: PurchaseCoreData & StripeStoreData = { 59 | currency: 'USD', 60 | price: '10', 61 | productId: 'test product id', 62 | purchased: 3243523432, 63 | subscriptionId: 'test subscription id' 64 | }; 65 | 66 | beforeEach(() => { 67 | // @ts-ignore 68 | requestConfigurator = {}; 69 | // @ts-ignore 70 | apiInteractor = {}; 71 | 72 | purchasesService = new PurchaseServiceImpl(requestConfigurator, apiInteractor); 73 | }); 74 | 75 | describe('sendStripePurchase tests', function () { 76 | // @ts-ignore 77 | const testRequest: NetworkRequest = {a: 'aa'}; 78 | 79 | test('purchase successfully sent', async () => { 80 | // given 81 | requestConfigurator.configureStripePurchaseRequest = jest.fn(() => testRequest); 82 | // @ts-ignore 83 | apiInteractor.execute = jest.fn(async () => testSuccessfulResponse); 84 | 85 | // when 86 | const res = await purchasesService.sendStripePurchase(testUserId, testStripePurchaseRequest); 87 | 88 | // then 89 | expect(res).toStrictEqual(expRes); 90 | expect(requestConfigurator.configureStripePurchaseRequest).toBeCalledWith(testUserId, testStripePurchaseRequest); 91 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 92 | }); 93 | 94 | test('send purchase request failed', async () => { 95 | // given 96 | requestConfigurator.configureStripePurchaseRequest = jest.fn(() => testRequest); 97 | apiInteractor.execute = jest.fn(async () => testErrorResponse); 98 | const expError = new QonversionError( 99 | QonversionErrorCode.BackendError, 100 | `Response code ${testErrorCode}, message: ${testErrorMessage}`, 101 | ); 102 | 103 | // when and then 104 | await expect(() => purchasesService.sendStripePurchase(testUserId, testStripePurchaseRequest)).rejects.toThrow(expError); 105 | expect(requestConfigurator.configureStripePurchaseRequest).toBeCalledWith(testUserId, testStripePurchaseRequest); 106 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/logger/logger.test.ts: -------------------------------------------------------------------------------- 1 | import {LoggerConfigProvider} from '../../../internal'; 2 | import {LogLevel} from '../../../index'; 3 | import LoggerImpl from '../../../internal/logger'; 4 | 5 | const testLogTag = 'tag'; 6 | let allowedLogLevel = LogLevel.Disabled; 7 | const loggerConfigProvider: LoggerConfigProvider = { 8 | getLogLevel(): LogLevel { 9 | return allowedLogLevel; 10 | }, 11 | getLogTag(): string { 12 | return testLogTag; 13 | } 14 | }; 15 | const someAdditionalParam = {someField: 'someValue'}; 16 | 17 | const logger = new LoggerImpl(loggerConfigProvider); 18 | 19 | describe('logging methods', function () { 20 | const loggerSpy = jest.spyOn(LoggerImpl.prototype as any, 'log'); 21 | 22 | test('verbose', () => { 23 | // given 24 | const message = 'test verbose message'; 25 | 26 | // when 27 | logger.verbose(message, someAdditionalParam); 28 | 29 | // then 30 | expect(loggerSpy).toHaveBeenCalledWith(LogLevel.Verbose, console.log, message, [someAdditionalParam]); 31 | }); 32 | 33 | test('info', () => { 34 | // given 35 | const message = 'test info message'; 36 | 37 | // when 38 | logger.info(message, someAdditionalParam); 39 | 40 | // then 41 | expect(loggerSpy).toHaveBeenCalledWith(LogLevel.Info, console.info, message, [someAdditionalParam]); 42 | }); 43 | 44 | test('warning', () => { 45 | // given 46 | const message = 'test warning message'; 47 | 48 | // when 49 | logger.warn(message, someAdditionalParam); 50 | 51 | // then 52 | expect(loggerSpy).toHaveBeenCalledWith(LogLevel.Warning, console.warn, message, [someAdditionalParam]); 53 | }); 54 | 55 | test('error', () => { 56 | // given 57 | const message = 'test error message'; 58 | 59 | // when 60 | logger.error(message, someAdditionalParam); 61 | 62 | // then 63 | expect(loggerSpy).toHaveBeenCalledWith(LogLevel.Error, console.error, message, [someAdditionalParam]); 64 | }); 65 | }); 66 | 67 | describe('core log method', function () { 68 | const testMessage = 'test message'; 69 | const expMessage = `${testLogTag}: ${testMessage}`; 70 | 71 | test('configured log level is higher than called one', () => { 72 | // given 73 | allowedLogLevel = LogLevel.Warning; 74 | const logMethod = jest.fn(); 75 | 76 | // when 77 | logger['log'](LogLevel.Error, logMethod, testMessage, [someAdditionalParam]); 78 | 79 | // then 80 | expect(logMethod).toHaveBeenCalledWith(expMessage, someAdditionalParam); 81 | }); 82 | 83 | test('configured log level is exact the same as called one', () => { 84 | // given 85 | allowedLogLevel = LogLevel.Warning; 86 | const logMethod = jest.fn(); 87 | 88 | // when 89 | logger['log'](LogLevel.Warning, logMethod, testMessage, [someAdditionalParam]); 90 | 91 | // then 92 | expect(logMethod).toHaveBeenCalledWith(expMessage, someAdditionalParam); 93 | }); 94 | 95 | test('configured log level is lower than called one', () => { 96 | // given 97 | allowedLogLevel = LogLevel.Warning; 98 | const logMethod = jest.fn(); 99 | 100 | // when 101 | logger['log'](LogLevel.Info, logMethod, testMessage, [someAdditionalParam]); 102 | 103 | // then 104 | expect(logMethod).not.toBeCalled(); 105 | }); 106 | 107 | test('calling without additional params', () => { 108 | // given 109 | allowedLogLevel = LogLevel.Warning; 110 | const logMethod = jest.fn(); 111 | 112 | // when 113 | logger['log'](LogLevel.Error, logMethod, testMessage, []); 114 | 115 | // then 116 | expect(logMethod).toHaveBeenCalledWith(expMessage); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/user/UserDataStorage.test.ts: -------------------------------------------------------------------------------- 1 | import {UserDataStorageImpl} from '../../../internal/user'; 2 | import {LocalStorage, StorageConstants} from '../../../internal/common'; 3 | import {QonversionError} from '../../../index'; 4 | 5 | describe('UserDataStorage tests', () => { 6 | let userDataStorage: UserDataStorageImpl; 7 | let localStorage: LocalStorage; 8 | 9 | const testIdentityId = 'test identity id'; 10 | const testOriginalId = 'test original id'; 11 | 12 | beforeEach(() => { 13 | // @ts-ignore 14 | localStorage = { 15 | getString: jest.fn(key => key === StorageConstants.OriginalUserId ? testOriginalId : testIdentityId), 16 | }; 17 | 18 | userDataStorage = new UserDataStorageImpl(localStorage); 19 | }); 20 | 21 | test('initialization', () => { 22 | // given 23 | 24 | // when 25 | // constructor call in beforeEach 26 | 27 | // then 28 | expect(localStorage.getString).toBeCalledWith(StorageConstants.OriginalUserId); 29 | expect(localStorage.getString).toBeCalledWith(StorageConstants.IdentityUserId); 30 | expect(userDataStorage['originalId']).toBe(testOriginalId); 31 | expect(userDataStorage['identityId']).toBe(testIdentityId); 32 | }); 33 | 34 | test('get original user id', () => { 35 | // given 36 | userDataStorage['originalId'] = testOriginalId; 37 | userDataStorage['identityId'] = undefined; 38 | 39 | // when 40 | const res = userDataStorage.getOriginalUserId(); 41 | 42 | // then 43 | expect(res).toBe(testOriginalId); 44 | }); 45 | 46 | test('get identity user id', () => { 47 | // given 48 | userDataStorage['originalId'] = undefined; 49 | userDataStorage['identityId'] = testIdentityId; 50 | 51 | // when 52 | const res = userDataStorage.getIdentityUserId(); 53 | 54 | // then 55 | expect(res).toBe(testIdentityId); 56 | }); 57 | 58 | test('require original user id when original id exist', () => { 59 | // given 60 | userDataStorage['originalId'] = testOriginalId; 61 | 62 | // when 63 | const res = userDataStorage.requireOriginalUserId(); 64 | 65 | // then 66 | expect(res).toBe(testOriginalId); 67 | }); 68 | 69 | test('require user id when original id does not exist', () => { 70 | // given 71 | userDataStorage['originalId'] = undefined; 72 | 73 | // when and then 74 | expect(() => { 75 | userDataStorage.requireOriginalUserId(); 76 | }).toThrow(QonversionError); 77 | }); 78 | 79 | test('clear identity id', () => { 80 | // given 81 | localStorage.remove = jest.fn(); 82 | userDataStorage['identityId'] = testIdentityId; 83 | 84 | // when 85 | userDataStorage.clearIdentityUserId(); 86 | 87 | // then 88 | expect(userDataStorage['identityId']).toBeUndefined(); 89 | expect(localStorage.remove).toBeCalledWith(StorageConstants.IdentityUserId); 90 | }); 91 | 92 | test('set identity id', () => { 93 | // given 94 | localStorage.putString = jest.fn(); 95 | userDataStorage['identityId'] = undefined; 96 | 97 | // when 98 | userDataStorage.setIdentityUserId(testIdentityId); 99 | 100 | // then 101 | expect(userDataStorage['identityId']).toBe(testIdentityId); 102 | expect(localStorage.putString).toBeCalledWith(StorageConstants.IdentityUserId, testIdentityId); 103 | }); 104 | 105 | test('set original id', () => { 106 | // given 107 | localStorage.putString = jest.fn(); 108 | userDataStorage['originalId'] = undefined; 109 | 110 | // when 111 | userDataStorage.setOriginalUserId(testOriginalId); 112 | 113 | // then 114 | expect(userDataStorage['originalId']).toBe(testOriginalId); 115 | expect(localStorage.putString).toBeCalledWith(StorageConstants.OriginalUserId, testOriginalId); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/utils/DelayedWorker.test.ts: -------------------------------------------------------------------------------- 1 | import {DelayedWorker, DelayedWorkerImpl} from '../../../internal/utils/DelayedWorker'; 2 | 3 | describe('DelayedWorker tests', () => { 4 | let delayedWorker: DelayedWorker; 5 | let testAction: () => Promise; 6 | const testDelay = 1000; 7 | 8 | beforeEach(() => { 9 | jest.resetAllMocks(); 10 | jest.useFakeTimers(); 11 | jest.spyOn(global, 'setTimeout'); 12 | jest.spyOn(global, 'clearTimeout'); 13 | 14 | testAction = jest.fn(async () => {}); 15 | delayedWorker = new DelayedWorkerImpl(); 16 | }); 17 | 18 | test('do normal delayed job', async () => { 19 | // given 20 | delayedWorker.isInProgress = jest.fn(() => false); 21 | 22 | // when 23 | delayedWorker.doDelayed(testDelay, testAction); 24 | 25 | // then 26 | expect(delayedWorker.isInProgress).toBeCalled(); 27 | // @ts-ignore 28 | expect(delayedWorker['timeoutId']).not.toBeUndefined(); 29 | expect(testAction).not.toBeCalled(); 30 | 31 | jest.advanceTimersByTime(testDelay / 2); 32 | expect(testAction).not.toBeCalled(); 33 | 34 | jest.advanceTimersByTime(testDelay / 2 + 1); 35 | await expect(testAction).toBeCalled(); 36 | // @ts-ignore 37 | expect(delayedWorker['timeoutId']).toBeUndefined(); 38 | }); 39 | 40 | test('do delayed job when another one is in progress', () => { 41 | // given 42 | delayedWorker.isInProgress = jest.fn(() => true); 43 | 44 | // when 45 | delayedWorker.doDelayed(testDelay, testAction); 46 | 47 | // then 48 | expect(delayedWorker.isInProgress).toBeCalled(); 49 | expect(testAction).not.toBeCalled(); 50 | 51 | jest.runAllTimers(); 52 | 53 | expect(testAction).not.toBeCalled(); 54 | }); 55 | 56 | test('do delayed job ignoring existing one', () => { 57 | // given 58 | // @ts-ignore 59 | delayedWorker['timeoutId'] = 500; 60 | const cancelSpy = jest.spyOn(delayedWorker, 'cancel'); 61 | 62 | // when 63 | delayedWorker.doDelayed(testDelay, testAction, true); 64 | 65 | // then 66 | expect(cancelSpy).toBeCalled(); 67 | // @ts-ignore 68 | expect(delayedWorker['timeoutId']).not.toBeUndefined(); 69 | expect(testAction).not.toBeCalled(); 70 | 71 | jest.runAllTimers(); 72 | expect(testAction).toBeCalled(); 73 | }); 74 | 75 | test('do immediately', () => { 76 | // given 77 | delayedWorker.cancel = jest.fn(); 78 | 79 | // when 80 | delayedWorker.doImmediately(testAction); 81 | 82 | // then 83 | expect(delayedWorker.cancel).toBeCalled(); 84 | expect(setTimeout).not.toBeCalled(); 85 | expect(testAction).toBeCalled(); 86 | }); 87 | 88 | test('cancelling started timeout', () => { 89 | // given 90 | const timeoutId = 500; 91 | // @ts-ignore 92 | delayedWorker['timeoutId'] = timeoutId; 93 | 94 | // when 95 | delayedWorker.cancel(); 96 | 97 | // then 98 | expect(clearTimeout).toBeCalledWith(timeoutId); 99 | // @ts-ignore 100 | expect(delayedWorker['timeoutId']).toBeUndefined(); 101 | }); 102 | 103 | test('cancelling undefined timeout', () => { 104 | // given 105 | 106 | // when 107 | delayedWorker.cancel(); 108 | 109 | // then 110 | expect(clearTimeout).not.toBeCalled(); 111 | // @ts-ignore 112 | expect(delayedWorker['timeoutId']).toBeUndefined(); 113 | }); 114 | 115 | test('work is in progress', () => { 116 | // given 117 | // @ts-ignore 118 | delayedWorker['timeoutId'] = 500; 119 | 120 | // when 121 | const res = delayedWorker.isInProgress(); 122 | 123 | // then 124 | expect(res).toBeTruthy(); 125 | }); 126 | 127 | test('no work in progress', () => { 128 | // given 129 | 130 | // when 131 | const res = delayedWorker.isInProgress(); 132 | 133 | // then 134 | expect(res).toBeFalsy(); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /sdk/src/internal/network/RequestConfigurator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiEndpoint, 3 | HeaderBuilder, 4 | NetworkRequest, 5 | RequestBody, 6 | RequestConfigurator, 7 | RequestHeaders, 8 | RequestType 9 | } from './types'; 10 | import {PrimaryConfigProvider} from '../types'; 11 | import {UserDataProvider} from '../user'; 12 | import {PurchaseCoreData, StripeStoreData} from '../../dto/Purchase'; 13 | import {Environment} from '../../dto/Environment'; 14 | import {UserPropertyData} from '../userProperties'; 15 | 16 | export class RequestConfiguratorImpl implements RequestConfigurator { 17 | private readonly headerBuilder: HeaderBuilder; 18 | private readonly baseUrl: string; 19 | private readonly primaryConfigProvider: PrimaryConfigProvider; 20 | private readonly userDataProvider: UserDataProvider 21 | 22 | constructor( 23 | headerBuilder: HeaderBuilder, 24 | baseUrl: string, 25 | primaryConfigProvider: PrimaryConfigProvider, 26 | userDataProvider: UserDataProvider 27 | ) { 28 | this.headerBuilder = headerBuilder; 29 | this.baseUrl = baseUrl; 30 | this.primaryConfigProvider = primaryConfigProvider; 31 | this.userDataProvider = userDataProvider; 32 | } 33 | 34 | configureUserRequest(id: string): NetworkRequest { 35 | const url = `${this.baseUrl}/${ApiEndpoint.Users}/${id}`; 36 | 37 | return this.configureRequest(url, RequestType.GET); 38 | } 39 | 40 | configureCreateUserRequest(id: string, environment: Environment): NetworkRequest { 41 | const url = `${this.baseUrl}/${ApiEndpoint.Users}/${id}`; 42 | const body = {environment}; 43 | 44 | return this.configureRequest(url, RequestType.POST, body); 45 | } 46 | 47 | configureUserPropertiesSendRequest(userId: string, properties: UserPropertyData[]): NetworkRequest { 48 | const url = `${this.baseUrl}/${ApiEndpoint.Users}/${userId}/${ApiEndpoint.Properties}`; 49 | return this.configureRequest(url, RequestType.POST, properties); 50 | } 51 | 52 | configureUserPropertiesGetRequest(userId: string): NetworkRequest { 53 | const url = `${this.baseUrl}/${ApiEndpoint.Users}/${userId}/${ApiEndpoint.Properties}`; 54 | return this.configureRequest(url, RequestType.GET); 55 | } 56 | 57 | configureCreateIdentityRequest(qonversionId: string, identityId: string): NetworkRequest { 58 | const url = `${this.baseUrl}/${ApiEndpoint.Identity}/${identityId}`; 59 | const body = {user_id: qonversionId}; 60 | 61 | return this.configureRequest(url, RequestType.POST, body); 62 | } 63 | 64 | configureIdentityRequest(identityId: string): NetworkRequest { 65 | const url = `${this.baseUrl}/${ApiEndpoint.Identity}/${identityId}`; 66 | 67 | return this.configureRequest(url, RequestType.GET); 68 | } 69 | 70 | configureEntitlementsRequest(userId: string): NetworkRequest { 71 | const url = `${this.baseUrl}/${ApiEndpoint.Users}/${userId}/entitlements`; 72 | 73 | return this.configureRequest(url, RequestType.GET); 74 | } 75 | 76 | configureStripePurchaseRequest(userId: string, data: PurchaseCoreData & StripeStoreData): NetworkRequest { 77 | const url = `${this.baseUrl}/${ApiEndpoint.Users}/${userId}/purchases`; 78 | const body = { 79 | price: data.price, 80 | currency: data.currency, 81 | stripe_store_data: { 82 | subscription_id: data.subscriptionId, 83 | product_id: data.productId, 84 | }, 85 | purchased: data.purchased, 86 | }; 87 | 88 | return this.configureRequest(url, RequestType.POST, body); 89 | } 90 | 91 | private configureRequest( 92 | url: string, 93 | type: RequestType, 94 | body?: RequestBody, 95 | additionalHeaders?: RequestHeaders 96 | ): NetworkRequest { 97 | let headers = this.headerBuilder.buildCommonHeaders(); 98 | if (additionalHeaders) { 99 | headers = {...headers, ...additionalHeaders}; 100 | } 101 | 102 | return { 103 | url, 104 | headers, 105 | type, 106 | body, 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /sdk/src/__integrationTests__/apiV3/UserService.test.ts: -------------------------------------------------------------------------------- 1 | import {User} from '../../dto/User'; 2 | import {Environment} from '../../dto/Environment'; 3 | import {expectQonversionErrorAsync, getCurrentTs, getDependencyAssembly} from '../utils'; 4 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 5 | import {TS_EPSILON} from '../constants'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 8 | // @ts-ignore 9 | // noinspection JSConstantReassignment 10 | global.localStorage = { 11 | getItem: jest.fn(), 12 | }; 13 | 14 | describe('users tests', function () { 15 | const dependenciesAssembly = getDependencyAssembly(); 16 | 17 | const userService = dependenciesAssembly.userService(); 18 | 19 | describe('POST users', function () { 20 | it('create production user', async () => { 21 | // given 22 | const testsStartTs = getCurrentTs(); 23 | const testUserId = 'testProd' + testsStartTs; 24 | const expectedUser: User = { 25 | created: 0, 26 | environment: Environment.Production, 27 | id: testUserId, 28 | identityId: undefined, 29 | }; 30 | 31 | // when 32 | const res = await userService.createUser(testUserId); 33 | const requestEndTs = getCurrentTs(); 34 | 35 | // then 36 | expect(res.created).toBeGreaterThanOrEqual(testsStartTs - TS_EPSILON); 37 | expect(res.created).toBeLessThanOrEqual(requestEndTs + TS_EPSILON); 38 | expectedUser.created = res.created; 39 | expect(res).toEqual(expectedUser); 40 | }); 41 | 42 | it('create existing user', async () => { 43 | // given 44 | const testsStartTs = getCurrentTs(); 45 | const testUserId = 'testExistingUser' + testsStartTs; 46 | await userService.createUser(testUserId); 47 | 48 | // when and then 49 | await expectQonversionErrorAsync( 50 | QonversionErrorCode.BackendError, 51 | 'Qonversion API returned an error. Response code 422, message: User with given uid already exists', 52 | async () => { 53 | await userService.createUser(testUserId); 54 | }, 55 | ); 56 | }); 57 | 58 | it('create sandbox user', async () => { 59 | // given 60 | const dependenciesAssembly = getDependencyAssembly({environment: Environment.Sandbox}); 61 | 62 | const userService = dependenciesAssembly.userService(); 63 | 64 | const testsStartTs = getCurrentTs(); 65 | 66 | const testUserId = 'testSandbox' + testsStartTs; 67 | const expectedUser: User = { 68 | created: 0, 69 | environment: Environment.Sandbox, 70 | id: testUserId, 71 | identityId: undefined, 72 | }; 73 | 74 | // when 75 | const res = await userService.createUser(testUserId); 76 | const requestEndTs = getCurrentTs(); 77 | 78 | // then 79 | expect(res.created).toBeGreaterThanOrEqual(testsStartTs - TS_EPSILON); 80 | expect(res.created).toBeLessThanOrEqual(requestEndTs + TS_EPSILON); 81 | expectedUser.created = res.created; 82 | expect(res).toEqual(expectedUser); 83 | }); 84 | }); 85 | 86 | describe('GET users', function () { 87 | it('get existing user', async () => { 88 | // given 89 | const testUserId = 'testGet' + Date.now(); 90 | const expUser = await userService.createUser(testUserId); 91 | 92 | // when 93 | const res = await userService.getUser(testUserId); 94 | 95 | // then 96 | expect(res).toEqual(expUser); 97 | }); 98 | 99 | it('get non-existent user', async () => { 100 | // given 101 | const nonExistentUserId = 'testNonExistent' + Date.now(); 102 | 103 | // when and then 104 | await expectQonversionErrorAsync( 105 | QonversionErrorCode.UserNotFound, 106 | 'Qonversion user not found. Id: ' + nonExistentUserId, 107 | async () => { 108 | await userService.getUser(nonExistentUserId); 109 | }, 110 | ); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/user/UserServiceDecorator.test.ts: -------------------------------------------------------------------------------- 1 | import {UserService, UserServiceDecorator} from '../../../internal/user'; 2 | import {QonversionError, QonversionErrorCode, User} from '../../../index'; 3 | 4 | const testUserId = 'test user id'; 5 | let userService: UserService; 6 | let userServiceDecorator: UserServiceDecorator; 7 | 8 | beforeEach(() => { 9 | // @ts-ignore 10 | userService = {}; 11 | userServiceDecorator = new UserServiceDecorator(userService); 12 | }); 13 | 14 | describe('createUser tests', () => { 15 | test('create user', () => { 16 | // given 17 | const promise = new Promise(() => {}); 18 | // @ts-ignore 19 | userService.createUser = jest.fn(() => promise); 20 | 21 | // when 22 | const res = userServiceDecorator.createUser(testUserId); 23 | 24 | // then 25 | expect(res).toStrictEqual(promise); 26 | expect(userService.createUser).toBeCalledWith(testUserId); 27 | }); 28 | }); 29 | 30 | describe('getUser tests', () => { 31 | test('another request is in progress', () => { 32 | // given 33 | const promise = new Promise(() => {}); 34 | // @ts-ignore 35 | userServiceDecorator['userLoadingPromise'] = promise; 36 | 37 | // when 38 | const res = userServiceDecorator.getUser(testUserId); 39 | 40 | // then 41 | expect(res).toStrictEqual(promise); 42 | }); 43 | 44 | test('no request is in progress', () => { 45 | // given 46 | const promise = new Promise(() => {}); 47 | // @ts-ignore 48 | userServiceDecorator['loadOrCreateUser'] = jest.fn(() => promise); 49 | 50 | // when 51 | const res = userServiceDecorator.getUser(testUserId); 52 | 53 | // then 54 | expect(res).toStrictEqual(promise); 55 | expect(userServiceDecorator['loadOrCreateUser']).toBeCalledWith(testUserId); 56 | expect(userServiceDecorator['userLoadingPromise']).toStrictEqual(promise); 57 | }); 58 | }); 59 | 60 | describe('loadOrCreateUser tests', () => { 61 | const user: User = { 62 | created: 0, 63 | environment: 'sandbox', 64 | id: testUserId, 65 | identityId: 'some identity' 66 | }; 67 | 68 | test('user already exists', async () => { 69 | // given 70 | userService.getUser = jest.fn(async () => user); 71 | 72 | // when 73 | const res = await userServiceDecorator['loadOrCreateUser'](testUserId); 74 | 75 | // then 76 | expect(res).toStrictEqual(user); 77 | expect(userService.getUser).toBeCalledWith(testUserId); 78 | }); 79 | 80 | test('user does not exist', async () => { 81 | // given 82 | const notFoundError = new QonversionError(QonversionErrorCode.UserNotFound); 83 | userService.getUser = jest.fn(async () => {throw notFoundError}); 84 | userService.createUser = jest.fn(async () => user); 85 | 86 | // when 87 | const res = await userServiceDecorator['loadOrCreateUser'](testUserId); 88 | 89 | // then 90 | expect(res).toStrictEqual(user); 91 | expect(userService.getUser).toBeCalledWith(testUserId); 92 | expect(userService.createUser).toBeCalledWith(testUserId); 93 | }); 94 | 95 | test('getUser throws unknown error', async () => { 96 | // given 97 | const unknownError = new Error(); 98 | userService.getUser = jest.fn(async () => {throw unknownError}); 99 | 100 | // when and then 101 | await expect(userServiceDecorator['loadOrCreateUser'](testUserId)).rejects.toThrow(unknownError); 102 | expect(userService.getUser).toBeCalledWith(testUserId); 103 | }); 104 | 105 | test('createUser throws unknown error', async () => { 106 | // given 107 | const unknownError = new Error(); 108 | const notFoundError = new QonversionError(QonversionErrorCode.UserNotFound); 109 | userService.getUser = jest.fn(async () => {throw notFoundError}); 110 | userService.createUser = jest.fn(async () => {throw unknownError}); 111 | 112 | // when and then 113 | await expect(userServiceDecorator['loadOrCreateUser'](testUserId)).rejects.toThrow(unknownError); 114 | expect(userService.getUser).toBeCalledWith(testUserId); 115 | expect(userService.createUser).toBeCalledWith(testUserId); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /sdk/src/__tests__/QonversionConfigBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Environment, 3 | LogLevel, 4 | QonversionConfig, 5 | QonversionConfigBuilder, 6 | QonversionErrorCode 7 | } from '../index'; 8 | import {LoggerConfig, NetworkConfig, PrimaryConfig} from "../types"; 9 | import {expectQonversionError} from './utils'; 10 | import {API_URL} from '../internal/network'; 11 | 12 | const packageJson = require('../../../package.json'); 13 | 14 | test('constructor', () => { 15 | // given 16 | const projectKey = "test_key"; 17 | 18 | // when 19 | const builder = new QonversionConfigBuilder(projectKey); 20 | 21 | // then 22 | expect(builder["projectKey"]).toBe(projectKey); 23 | }); 24 | 25 | test('setting environment type', () => { 26 | // given 27 | const builder = new QonversionConfigBuilder('test'); 28 | const environment = Environment.Sandbox; 29 | 30 | // when 31 | builder.setEnvironment(environment); 32 | 33 | // then 34 | expect(builder["environment"]).toBe(environment); 35 | }); 36 | 37 | test('setting log level', () => { 38 | // given 39 | const builder = new QonversionConfigBuilder('test'); 40 | const logLevel = LogLevel.Error; 41 | 42 | // when 43 | builder.setLogLevel(logLevel); 44 | 45 | // then 46 | expect(builder["logLevel"]).toBe(logLevel); 47 | }); 48 | 49 | test('setting log tag', () => { 50 | // given 51 | const builder = new QonversionConfigBuilder('test'); 52 | const logTag = "test tag"; 53 | 54 | // when 55 | builder.setLogTag(logTag); 56 | 57 | // then 58 | expect(builder["logTag"]).toBe(logTag); 59 | }); 60 | 61 | test('successful build with full list of arguments', () => { 62 | // given 63 | const mockLogLevel = LogLevel.Warning; 64 | const mockLogTag = "test tag"; 65 | const mockEnvironment = Environment.Sandbox; 66 | const projectKey = "test key"; 67 | 68 | const builder = new QonversionConfigBuilder(projectKey); 69 | builder["logLevel"] = mockLogLevel; 70 | builder["logTag"] = mockLogTag; 71 | builder["environment"] = mockEnvironment; 72 | 73 | const expPrimaryConfig: PrimaryConfig = { 74 | projectKey, 75 | environment: mockEnvironment, 76 | sdkVersion: packageJson.version, 77 | }; 78 | const expLoggerConfig: LoggerConfig = { 79 | logLevel: mockLogLevel, 80 | logTag: mockLogTag, 81 | }; 82 | const expNetworkConfig: NetworkConfig = { 83 | canSendRequests: true, 84 | apiUrl: API_URL, 85 | }; 86 | const expResult: QonversionConfig = { 87 | primaryConfig: expPrimaryConfig, 88 | loggerConfig: expLoggerConfig, 89 | networkConfig: expNetworkConfig, 90 | }; 91 | 92 | // when 93 | const result = builder.build() 94 | 95 | // then 96 | expect(result).toStrictEqual(expResult); 97 | }); 98 | 99 | test('successful build without full list of arguments', () => { 100 | // given 101 | const defaultLogLevel = LogLevel.Info; 102 | const defaultLogTag = "Qonversion"; 103 | const defaultEnvironment = Environment.Production; 104 | const defaultCanSendRequests = true; 105 | const projectKey = "test key"; 106 | 107 | const builder = new QonversionConfigBuilder(projectKey); 108 | 109 | const expPrimaryConfig: PrimaryConfig = { 110 | projectKey, 111 | environment: defaultEnvironment, 112 | sdkVersion: packageJson.version, 113 | }; 114 | const expLoggerConfig: LoggerConfig = { 115 | logLevel: defaultLogLevel, 116 | logTag: defaultLogTag, 117 | }; 118 | const expNetworkConfig: NetworkConfig = { 119 | canSendRequests: defaultCanSendRequests, 120 | apiUrl: API_URL, 121 | }; 122 | const expResult: QonversionConfig = { 123 | primaryConfig: expPrimaryConfig, 124 | loggerConfig: expLoggerConfig, 125 | networkConfig: expNetworkConfig, 126 | }; 127 | 128 | // when 129 | const result = builder.build() 130 | 131 | // then 132 | expect(result).toStrictEqual(expResult); 133 | }); 134 | 135 | test('building with blank project key', () => { 136 | // given 137 | const builder = new QonversionConfigBuilder(""); 138 | const testingMethod = builder.build.bind(builder); 139 | 140 | // when and then 141 | expectQonversionError(QonversionErrorCode.ConfigPreparation, testingMethod); 142 | }); 143 | -------------------------------------------------------------------------------- /sdk/src/internal/QonversionInternal.ts: -------------------------------------------------------------------------------- 1 | import {QonversionInstance} from '../types'; 2 | import {InternalConfig} from './InternalConfig'; 3 | import {LogLevel} from '../dto/LogLevel'; 4 | import {Environment} from '../dto/Environment'; 5 | import {DependenciesAssembly} from './di/DependenciesAssembly'; 6 | import Qonversion from '../Qonversion'; 7 | import {UserPropertyKey} from '../dto/UserPropertyKey'; 8 | import {UserPropertiesController} from './userProperties'; 9 | import {UserController} from './user'; 10 | import {EntitlementsController} from './entitlements'; 11 | import {Entitlement} from '../dto/Entitlement'; 12 | import {PurchasesController} from './purchases'; 13 | import {PurchaseCoreData, StripeStoreData, UserPurchase} from '../dto/Purchase'; 14 | import {Logger} from './logger'; 15 | import {UserProperties} from '../dto/UserProperties'; 16 | 17 | export class QonversionInternal implements QonversionInstance { 18 | private readonly internalConfig: InternalConfig; 19 | private readonly userPropertiesController: UserPropertiesController; 20 | private readonly userController: UserController; 21 | private readonly entitlementsController: EntitlementsController; 22 | private readonly purchasesController: PurchasesController; 23 | private readonly logger: Logger; 24 | 25 | constructor(internalConfig: InternalConfig, dependenciesAssembly: DependenciesAssembly) { 26 | this.internalConfig = internalConfig; 27 | 28 | this.logger = dependenciesAssembly.logger(); 29 | this.userPropertiesController = dependenciesAssembly.userPropertiesController(); 30 | this.userController = dependenciesAssembly.userController(); 31 | this.entitlementsController = dependenciesAssembly.entitlementsController(); 32 | this.purchasesController = dependenciesAssembly.purchasesController(); 33 | 34 | this.logger.verbose("The QonversionInstance is created"); 35 | } 36 | 37 | sendStripePurchase(data: PurchaseCoreData & StripeStoreData): Promise { 38 | this.logger.verbose("sendStripePurchase() call"); 39 | return this.purchasesController.sendStripePurchase(data); 40 | } 41 | 42 | entitlements(): Promise { 43 | this.logger.verbose("entitlements() call"); 44 | return this.entitlementsController.getEntitlements(); 45 | } 46 | 47 | identify(userId: string): Promise { 48 | this.logger.verbose("identify() call"); 49 | return this.userController.identify(userId); 50 | } 51 | 52 | logout(): Promise { 53 | this.logger.verbose("logout() call"); 54 | return this.userController.logout(); 55 | } 56 | 57 | setCustomUserProperty(key: string, value: string): void { 58 | this.logger.verbose("setCustomUserProperty() call"); 59 | this.userPropertiesController.setProperty(key, value); 60 | } 61 | 62 | setUserProperties(userProperties: Record): void { 63 | this.logger.verbose("setUserProperties() call"); 64 | this.userPropertiesController.setProperties(userProperties); 65 | } 66 | 67 | setUserProperty(property: UserPropertyKey, value: string): void { 68 | this.logger.verbose("setUserProperty() call"); 69 | this.userPropertiesController.setProperty(property, value); 70 | } 71 | 72 | async userProperties(): Promise { 73 | this.logger.verbose("userProperties() call"); 74 | return await this.userPropertiesController.getProperties(); 75 | } 76 | 77 | finish() { 78 | this.logger.verbose("finish() call"); 79 | 80 | if (Qonversion["backingInstance"] == this) { 81 | Qonversion["backingInstance"] = undefined 82 | } 83 | } 84 | 85 | setEnvironment(environment: Environment) { 86 | this.logger.verbose("setEnvironment() call"); 87 | this.internalConfig.primaryConfig = {...this.internalConfig.primaryConfig, environment}; 88 | } 89 | 90 | setLogLevel(logLevel: LogLevel) { 91 | this.logger.verbose("setLogLevel() call"); 92 | this.internalConfig.loggerConfig = {...this.internalConfig.loggerConfig, logLevel}; 93 | } 94 | 95 | setLogTag(logTag: string) { 96 | this.logger.verbose("setLogTag() call"); 97 | this.internalConfig.loggerConfig = {...this.internalConfig.loggerConfig, logTag}; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/InternalConfig.test.ts: -------------------------------------------------------------------------------- 1 | import {LoggerConfig, NetworkConfig, PrimaryConfig, QonversionConfig} from '../../types'; 2 | import {Environment, LogLevel} from '../../index'; 3 | import { 4 | InternalConfig, 5 | EnvironmentProvider, 6 | LoggerConfigProvider, 7 | NetworkConfigHolder, 8 | PrimaryConfigProvider 9 | } from '../../internal'; 10 | 11 | let primaryConfig: PrimaryConfig; 12 | let networkConfig: NetworkConfig; 13 | let loggerConfig: LoggerConfig; 14 | let internalConfig: InternalConfig; 15 | 16 | const canSendRequestsInitial = true; 17 | 18 | beforeAll(() => { 19 | primaryConfig = { 20 | environment: Environment.Sandbox, 21 | projectKey: '', 22 | sdkVersion: '', 23 | }; 24 | 25 | networkConfig = { 26 | canSendRequests: canSendRequestsInitial, 27 | }; 28 | 29 | loggerConfig = { 30 | logTag: '', 31 | logLevel: LogLevel.Warning, 32 | }; 33 | }); 34 | 35 | beforeEach(() => { 36 | const qonversionConfig: QonversionConfig = {loggerConfig, networkConfig, primaryConfig}; 37 | internalConfig = new InternalConfig(qonversionConfig); 38 | }); 39 | 40 | describe('EnvironmentProvider tests', () => { 41 | const projectKey = "projectKey"; 42 | const environment = Environment.Sandbox; 43 | 44 | test('get environment', () => { 45 | // given 46 | internalConfig.primaryConfig = {projectKey, environment, sdkVersion: ''}; 47 | const environmentProvider: EnvironmentProvider = internalConfig; 48 | 49 | // when 50 | const resEnvironment = environmentProvider.getEnvironment(); 51 | 52 | // then 53 | expect(resEnvironment).toBe(environment); 54 | }); 55 | 56 | test('is sandbox when sandbox env', () => { 57 | // given 58 | internalConfig.primaryConfig = {projectKey, environment: Environment.Sandbox, sdkVersion: ''}; 59 | const environmentProvider: EnvironmentProvider = internalConfig; 60 | 61 | // when 62 | const isSandbox = environmentProvider.isSandbox(); 63 | 64 | // then 65 | expect(isSandbox).toBeTruthy(); 66 | }); 67 | 68 | test('is not sandbox when prod env', () => { 69 | // given 70 | internalConfig.primaryConfig = {projectKey, environment: Environment.Production, sdkVersion: ''}; 71 | const environmentProvider: EnvironmentProvider = internalConfig; 72 | 73 | // when 74 | const isSandbox = environmentProvider.isSandbox(); 75 | 76 | // then 77 | expect(isSandbox).toBeFalsy(); 78 | }); 79 | }); 80 | 81 | describe('LoggerConfigProvider tests', () => { 82 | const logLevel = LogLevel.Warning; 83 | const logTag = 'logTag'; 84 | const loggerConfig: LoggerConfig = {logLevel, logTag}; 85 | 86 | test('get log level', () => { 87 | // given 88 | internalConfig.loggerConfig = loggerConfig; 89 | const loggerConfigProvider: LoggerConfigProvider = internalConfig; 90 | 91 | // when 92 | const resLogLevel = loggerConfigProvider.getLogLevel(); 93 | 94 | // then 95 | expect(resLogLevel).toBe(logLevel); 96 | }); 97 | 98 | test('get log tag', () => { 99 | // given 100 | internalConfig.loggerConfig = loggerConfig; 101 | const loggerConfigProvider: LoggerConfigProvider = internalConfig; 102 | 103 | // when 104 | const resLogTag = loggerConfigProvider.getLogTag(); 105 | 106 | // then 107 | expect(resLogTag).toBe(logTag); 108 | }); 109 | }); 110 | 111 | describe('NetworkConfigHolder tests', () => { 112 | test('get can send requests', () => { 113 | // given 114 | const networkConfigHolder: NetworkConfigHolder = internalConfig; 115 | 116 | // when 117 | const canSendRequests = networkConfigHolder.canSendRequests(); 118 | 119 | // then 120 | expect(canSendRequests).toBe(canSendRequestsInitial); 121 | }); 122 | 123 | test('set can send requests', () => { 124 | // given 125 | const expCanSendRequests = false; 126 | const networkConfigHolder: NetworkConfigHolder = internalConfig; 127 | 128 | // when 129 | networkConfigHolder.setCanSendRequests(expCanSendRequests); 130 | 131 | // then 132 | expect(internalConfig.networkConfig.canSendRequests).toBe(expCanSendRequests); 133 | }); 134 | }); 135 | 136 | describe('PrimaryConfigProvider tests', () => { 137 | test('get primary config', () => { 138 | // given 139 | const primaryConfigProvider: PrimaryConfigProvider = internalConfig; 140 | 141 | // when 142 | const resPrimaryConfig = primaryConfigProvider.getPrimaryConfig(); 143 | 144 | // then 145 | expect(resPrimaryConfig).toBe(primaryConfig); 146 | }); 147 | }); -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/entitlements/EntitlementsService.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiInteractor, 3 | RequestConfigurator, 4 | NetworkRequest, 5 | ApiResponseError, 6 | ApiResponseSuccess 7 | } from '../../../internal/network'; 8 | import { 9 | Entitlement, 10 | EntitlementSource, 11 | PeriodType, 12 | QonversionError, 13 | QonversionErrorCode, 14 | RenewState 15 | } from '../../../index'; 16 | import {HTTP_CODE_NOT_FOUND} from '../../../internal/network/constants'; 17 | import { 18 | EntitlementApi, 19 | EntitlementsResponse, 20 | EntitlementsService, 21 | EntitlementsServiceImpl 22 | } from '../../../internal/entitlements'; 23 | 24 | let requestConfigurator: RequestConfigurator; 25 | let apiInteractor: ApiInteractor; 26 | let entitlementsService: EntitlementsService; 27 | const testUserId = 'test user id'; 28 | 29 | const apiEntitlement: EntitlementApi = { 30 | active: true, 31 | started: 10, 32 | expires: 100, 33 | id: 'test entitlement', 34 | source: 'stripe', 35 | product: { 36 | product_id: 'test product', 37 | subscription: { 38 | current_period_type: PeriodType.Trial, 39 | renew_state: RenewState.WillRenew, 40 | }, 41 | }, 42 | }; 43 | 44 | const apiPayload: EntitlementsResponse = { 45 | data: [apiEntitlement], 46 | object: 'list' 47 | }; 48 | const testSuccessfulResponse: ApiResponseSuccess = { 49 | code: 200, 50 | data: apiPayload, 51 | isSuccess: true 52 | }; 53 | const testErrorCode = 500; 54 | const testErrorMessage = 'Test error message'; 55 | const testErrorResponse: ApiResponseError = { 56 | code: testErrorCode, 57 | apiCode: '', 58 | message: testErrorMessage, 59 | type: '', 60 | isSuccess: false, 61 | }; 62 | const expRes: Entitlement[] = [{ 63 | active: true, 64 | started: 10, 65 | expires: 100, 66 | id: 'test entitlement', 67 | source: EntitlementSource.Stripe, 68 | product: { 69 | productId: 'test product', 70 | subscription: { 71 | currentPeriodType: PeriodType.Trial, 72 | renewState: RenewState.WillRenew, 73 | }, 74 | }, 75 | }]; 76 | 77 | beforeEach(() => { 78 | // @ts-ignore 79 | requestConfigurator = {}; 80 | // @ts-ignore 81 | apiInteractor = {}; 82 | 83 | entitlementsService = new EntitlementsServiceImpl(requestConfigurator, apiInteractor); 84 | }); 85 | 86 | describe('getEntitlements tests', function () { 87 | // @ts-ignore 88 | const testRequest: NetworkRequest = {a: 'aa'}; 89 | 90 | test('entitlements successfully received', async () => { 91 | // given 92 | requestConfigurator.configureEntitlementsRequest = jest.fn(() => testRequest); 93 | // @ts-ignore 94 | apiInteractor.execute = jest.fn(async () => testSuccessfulResponse); 95 | 96 | // when 97 | const res = await entitlementsService.getEntitlements(testUserId); 98 | 99 | // then 100 | expect(res).toStrictEqual(expRes); 101 | expect(requestConfigurator.configureEntitlementsRequest).toBeCalledWith(testUserId); 102 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 103 | }); 104 | 105 | test('entitlements request failed', async () => { 106 | // given 107 | requestConfigurator.configureEntitlementsRequest = jest.fn(() => testRequest); 108 | apiInteractor.execute = jest.fn(async () => testErrorResponse); 109 | const expError = new QonversionError( 110 | QonversionErrorCode.BackendError, 111 | `Response code ${testErrorCode}, message: ${testErrorMessage}`, 112 | ); 113 | 114 | // when and then 115 | await expect(() => entitlementsService.getEntitlements(testUserId)).rejects.toThrow(expError); 116 | expect(requestConfigurator.configureEntitlementsRequest).toBeCalledWith(testUserId); 117 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 118 | }); 119 | 120 | test('user does not exist', async () => { 121 | // given 122 | const testUserNotFoundResponse: ApiResponseError = { 123 | code: HTTP_CODE_NOT_FOUND, 124 | apiCode: '', 125 | message: testErrorMessage, 126 | type: '', 127 | isSuccess: false, 128 | }; 129 | requestConfigurator.configureEntitlementsRequest = jest.fn(() => testRequest); 130 | apiInteractor.execute = jest.fn(async () => testUserNotFoundResponse); 131 | const expError = new QonversionError( 132 | QonversionErrorCode.UserNotFound, 133 | `User id: ${testUserId}`, 134 | ); 135 | 136 | // when and then 137 | await expect(() => entitlementsService.getEntitlements(testUserId)).rejects.toThrow(expError); 138 | expect(requestConfigurator.configureEntitlementsRequest).toBeCalledWith(testUserId); 139 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /sdk/src/internal/network/ApiInteractor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiError, 3 | ApiInteractor, 4 | NetworkClient, 5 | NetworkRequest, 6 | ApiResponseError, 7 | ApiResponseSuccess, 8 | NetworkRetryConfig, 9 | RawNetworkResponse 10 | } from './types'; 11 | import {RetryDelayCalculator} from './RetryDelayCalculator'; 12 | import {NetworkConfigHolder} from '../types'; 13 | import {RetryPolicy, RetryPolicyExponential, RetryPolicyInfiniteExponential} from './RetryPolicy'; 14 | import {QonversionError} from '../../exception/QonversionError'; 15 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 16 | import {delay, isInternalServerErrorResponse, isSuccessfulResponse} from './utils'; 17 | import {ERROR_CODES_BLOCKING_FURTHER_EXECUTIONS} from './constants'; 18 | 19 | export class ApiInteractorImpl implements ApiInteractor { 20 | private readonly networkClient: NetworkClient; 21 | private readonly delayCalculator: RetryDelayCalculator; 22 | private readonly configHolder: NetworkConfigHolder; 23 | private readonly defaultRetryPolicy: RetryPolicy; 24 | 25 | constructor( 26 | networkClient: NetworkClient, 27 | delayCalculator: RetryDelayCalculator, 28 | configHolder: NetworkConfigHolder, 29 | defaultRetryPolicy: RetryPolicy = new RetryPolicyExponential(), 30 | ) { 31 | this.networkClient = networkClient; 32 | this.delayCalculator = delayCalculator; 33 | this.configHolder = configHolder; 34 | this.defaultRetryPolicy = defaultRetryPolicy; 35 | } 36 | 37 | async execute( 38 | request: NetworkRequest, 39 | retryPolicy: RetryPolicy = this.defaultRetryPolicy, 40 | attemptIndex: number = 0, 41 | ): Promise | ApiResponseError> { 42 | if (!this.configHolder.canSendRequests()) { 43 | throw new QonversionError(QonversionErrorCode.RequestDenied); 44 | } 45 | 46 | let executionError: QonversionError | undefined = undefined; 47 | let response: RawNetworkResponse | undefined = undefined; 48 | 49 | try { 50 | response = await this.networkClient.execute(request); 51 | } catch (cause) { 52 | if (cause instanceof QonversionError) { 53 | executionError = cause; 54 | } else { 55 | throw cause; 56 | } 57 | } 58 | 59 | if (response && isSuccessfulResponse(response.code)) { 60 | return { 61 | code: response.code, 62 | data: response.payload, 63 | isSuccess: true, 64 | }; 65 | } 66 | 67 | if (response && ERROR_CODES_BLOCKING_FURTHER_EXECUTIONS.includes(response.code)) { 68 | this.configHolder.setCanSendRequests(false); 69 | return ApiInteractorImpl.getErrorResponse(response, executionError); 70 | } 71 | 72 | const shouldTryToRetry = (!!response && isInternalServerErrorResponse(response.code)) || !!executionError; 73 | if (shouldTryToRetry) { 74 | const retryConfig = this.prepareRetryConfig(retryPolicy, attemptIndex); 75 | if (retryConfig.shouldRetry) { 76 | await delay(retryConfig.delay); 77 | return await this.execute(request, retryPolicy, retryConfig.attemptIndex) 78 | } 79 | } 80 | 81 | return ApiInteractorImpl.getErrorResponse(response, executionError); 82 | } 83 | 84 | static getErrorResponse(response?: RawNetworkResponse, executionError?: Error): ApiResponseError { 85 | if (response) { 86 | const apiError: ApiError = response.payload.error; 87 | return { 88 | code: response.code, 89 | message: apiError.message, 90 | type: apiError.type, 91 | apiCode: apiError.code, 92 | isSuccess: false, 93 | }; 94 | } else if (executionError) { 95 | throw executionError; 96 | } else { 97 | // Unacceptable state. 98 | throw new Error('Unreachable code. Either response or executionError should be defined'); 99 | } 100 | } 101 | 102 | prepareRetryConfig(retryPolicy: RetryPolicy, attemptIndex: number): NetworkRetryConfig { 103 | let shouldRetry = false; 104 | const newAttemptIndex = attemptIndex + 1; 105 | let minDelay = 0; 106 | let delay = 0; 107 | 108 | if (retryPolicy instanceof RetryPolicyInfiniteExponential) { 109 | shouldRetry = true; 110 | minDelay = retryPolicy.minDelay; 111 | } else if (retryPolicy instanceof RetryPolicyExponential) { 112 | shouldRetry = retryPolicy.retryCount > attemptIndex; 113 | minDelay = retryPolicy.minDelay; 114 | } 115 | 116 | if (minDelay < 0) { 117 | shouldRetry = false; 118 | } 119 | 120 | if (shouldRetry) { 121 | delay = this.delayCalculator.countDelay(minDelay, newAttemptIndex); 122 | } 123 | 124 | return { 125 | shouldRetry, 126 | attemptIndex: newAttemptIndex, 127 | delay, 128 | }; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/entitlements/EntitlementsController.test.ts: -------------------------------------------------------------------------------- 1 | import {UserControllerImpl, UserDataStorage} from '../../../internal/user'; 2 | import {Logger} from '../../../internal/logger'; 3 | import {EntitlementsController, EntitlementsService, EntitlementsControllerImpl} from '../../../internal/entitlements'; 4 | import {Entitlement, QonversionError, QonversionErrorCode} from '../../../index'; 5 | 6 | let entitlementsService: EntitlementsService; 7 | let userDataStorage: UserDataStorage; 8 | let logger: Logger; 9 | let userController: UserControllerImpl; 10 | let entitlementsController: EntitlementsController; 11 | 12 | const testUserId = 'test user id'; 13 | const testEntitlements: Entitlement[] = [{ 14 | active: true, 15 | started: 1, 16 | expires: 2, 17 | id: 'test entitlement 1', 18 | }, { 19 | active: false, 20 | started: 100, 21 | expires: 200, 22 | id: 'test entitlement 2', 23 | }]; 24 | 25 | beforeEach(() => { 26 | // @ts-ignore 27 | userController = {}; 28 | // @ts-ignore 29 | entitlementsService = {}; 30 | // @ts-ignore 31 | userDataStorage = {}; 32 | // @ts-ignore 33 | logger = { 34 | verbose: jest.fn(), 35 | info: jest.fn(), 36 | error: jest.fn(), 37 | }; 38 | entitlementsController = new EntitlementsControllerImpl(userController, entitlementsService, userDataStorage, logger); 39 | }); 40 | 41 | describe('getEntitlements tests', () => { 42 | test('successfully get entitlements', async () => { 43 | // given 44 | userDataStorage.requireOriginalUserId = jest.fn(() => testUserId); 45 | entitlementsService.getEntitlements = jest.fn(async () => testEntitlements); 46 | 47 | // when 48 | const res = await entitlementsController.getEntitlements(); 49 | 50 | // then 51 | expect(res).toStrictEqual(testEntitlements); 52 | expect(userDataStorage.requireOriginalUserId).toBeCalled(); 53 | expect(entitlementsService.getEntitlements).toBeCalledWith(testUserId); 54 | expect(logger.verbose).toBeCalledWith('Requesting entitlements', {userId: testUserId}); 55 | expect(logger.info).toBeCalledWith('Successfully received entitlements', testEntitlements); 56 | }); 57 | 58 | test('unknown error while getting entitlements', async () => { 59 | // given 60 | userDataStorage.requireOriginalUserId = jest.fn(() => testUserId); 61 | const unknownError = new Error('unknown error'); 62 | entitlementsService.getEntitlements = jest.fn(async () => {throw unknownError}); 63 | 64 | // when and then 65 | await expect(entitlementsController.getEntitlements()).rejects.toThrow(unknownError); 66 | expect(entitlementsService.getEntitlements).toBeCalledWith(testUserId); 67 | expect(logger.verbose).toBeCalledWith('Requesting entitlements', {userId: testUserId}); 68 | expect(logger.error).toBeCalledWith('Failed to request entitlements', unknownError); 69 | }); 70 | 71 | test('user not found and created successfully', async () => { 72 | // given 73 | userDataStorage.requireOriginalUserId = jest.fn(() => testUserId); 74 | const userNotFoundError = new QonversionError(QonversionErrorCode.UserNotFound); 75 | entitlementsService.getEntitlements = jest.fn(async () => {throw userNotFoundError}); 76 | // @ts-ignore 77 | userController.createUser = jest.fn(async () => {}); 78 | 79 | // when 80 | const res = await entitlementsController.getEntitlements(); 81 | 82 | // then 83 | expect(res).toStrictEqual([]); 84 | expect(entitlementsService.getEntitlements).toBeCalledWith(testUserId); 85 | expect(logger.verbose).toBeCalledWith('Requesting entitlements', {userId: testUserId}); 86 | expect(logger.verbose).toBeCalledWith('User is not registered. Creating new one'); 87 | expect(userController.createUser).toBeCalled(); 88 | expect(logger.error).not.toBeCalled(); 89 | }); 90 | 91 | test('user not found and creation fails', async () => { 92 | // given 93 | userDataStorage.requireOriginalUserId = jest.fn(() => testUserId); 94 | const userNotFoundError = new QonversionError(QonversionErrorCode.UserNotFound); 95 | entitlementsService.getEntitlements = jest.fn(async () => {throw userNotFoundError}); 96 | const userCreationError = new QonversionError(QonversionErrorCode.BackendError); 97 | userController.createUser = jest.fn(async () => {throw userCreationError}); 98 | 99 | // when 100 | const res = await entitlementsController.getEntitlements(); 101 | 102 | // then 103 | expect(res).toStrictEqual([]); 104 | expect(entitlementsService.getEntitlements).toBeCalledWith(testUserId); 105 | expect(logger.verbose).toBeCalledWith('Requesting entitlements', {userId: testUserId}); 106 | expect(logger.verbose).toBeCalledWith('User is not registered. Creating new one'); 107 | expect(logger.error).toBeCalledWith('Failed to create new user while requesting entitlements', userCreationError); 108 | expect(userController.createUser).toBeCalled(); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /sdk/src/__integrationTests__/aegis/IdentityService.test.ts: -------------------------------------------------------------------------------- 1 | import {expectQonversionErrorAsync, getDependencyAssembly} from '../utils'; 2 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 3 | import {AEGIS_URL} from '../constants'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | // @ts-ignore 7 | // noinspection JSConstantReassignment 8 | global.localStorage = { 9 | getItem: jest.fn(), 10 | }; 11 | 12 | describe('identities tests', function () { 13 | const dependenciesAssembly = getDependencyAssembly({apiUrl: AEGIS_URL}); 14 | 15 | const userService = dependenciesAssembly.userService(); 16 | const identityService = dependenciesAssembly.identityService(); 17 | 18 | describe('POST identities', function () { 19 | it('create correct identity', async () => { 20 | // given 21 | const identityId = 'testCorrectIdentity' + Date.now(); 22 | const userId = 'testCorrectIdentityUid' + Date.now(); 23 | await userService.createUser(userId); 24 | 25 | // when 26 | const res = await identityService.createIdentity(userId, identityId); 27 | 28 | // then 29 | expect(res).toEqual(userId); 30 | }); 31 | 32 | it('create same identity above existing', async () => { 33 | // given 34 | const identityId = 'testExistingIdentity' + Date.now(); 35 | const userId = 'testCorrectIdentityUid' + Date.now(); 36 | await userService.createUser(userId); 37 | await identityService.createIdentity(userId, identityId); 38 | 39 | // when and then 40 | await expectQonversionErrorAsync( 41 | QonversionErrorCode.BackendError, 42 | 'Qonversion API returned an error. Response code 422, message: identity already exists: user already converted', 43 | async () => { 44 | await identityService.createIdentity(userId, identityId); 45 | }, 46 | ); 47 | }); 48 | 49 | it('create different identity above existing', async () => { 50 | // given 51 | const identityId = 'testExistingIdentity' + Date.now(); 52 | const userId = 'testCorrectIdentityUid' + Date.now(); 53 | await userService.createUser(userId); 54 | await identityService.createIdentity(userId, identityId); 55 | 56 | // when and then 57 | await expectQonversionErrorAsync( 58 | QonversionErrorCode.BackendError, 59 | 'Qonversion API returned an error. Response code 422, message: identity for provided user id already exists', 60 | async () => { 61 | await identityService.createIdentity(userId, identityId + 'another'); 62 | }, 63 | ); 64 | }); 65 | 66 | it('create identity which was already used for another user', async () => { 67 | // given 68 | const identityId = 'testExistingIdentity' + Date.now(); 69 | const identifierUserId = 'testIdentifiedUid' + Date.now(); 70 | await userService.createUser(identifierUserId); 71 | await identityService.createIdentity(identifierUserId, identityId); 72 | 73 | const nonIdentifierUserId = 'testNonIdentifiedUid' + Date.now(); 74 | await userService.createUser(nonIdentifierUserId); 75 | 76 | // when and then 77 | await expectQonversionErrorAsync( 78 | QonversionErrorCode.BackendError, 79 | 'Qonversion API returned an error. Response code 422, message: identity already exists: it\'s linked to another user', 80 | async () => { 81 | await identityService.createIdentity(nonIdentifierUserId, identityId); 82 | }, 83 | ); 84 | }); 85 | 86 | it('create identity for non-existent user', async () => { 87 | // given 88 | const identityId = 'testIdentityForNonExistentUser' + Date.now(); 89 | const nonExistentUserId = 'testNonExistentUid' + Date.now(); 90 | 91 | // when 92 | const res = await identityService.createIdentity(nonExistentUserId, identityId); 93 | 94 | // then 95 | expect(res).toEqual(nonExistentUserId); 96 | }); 97 | }); 98 | 99 | describe('GET identities', function () { 100 | it('get existing identity', async () => { 101 | // given 102 | const identityId = 'testExistingIdentity' + Date.now(); 103 | const userId = 'testExistingUid' + Date.now(); 104 | await userService.createUser(userId); 105 | await identityService.createIdentity(userId, identityId); 106 | 107 | // when 108 | const res = await identityService.obtainIdentity(identityId); 109 | 110 | // then 111 | expect(res).toEqual(userId); 112 | }); 113 | 114 | it('get non-existent identity', async () => { 115 | // given 116 | const identityId = 'testNonExistentIdentity' + Date.now(); 117 | 118 | // when and then 119 | await expectQonversionErrorAsync( 120 | QonversionErrorCode.IdentityNotFound, 121 | 'User with requested identity not found. Id: ' + identityId, 122 | async () => { 123 | await identityService.obtainIdentity(identityId); 124 | }, 125 | ); 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /sdk/src/__integrationTests__/apiV3/IdentityService.test.ts: -------------------------------------------------------------------------------- 1 | import {expectQonversionErrorAsync, getDependencyAssembly} from '../utils'; 2 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-ignore 6 | // noinspection JSConstantReassignment 7 | global.localStorage = { 8 | getItem: jest.fn(), 9 | }; 10 | 11 | describe('identities tests', function () { 12 | const dependenciesAssembly = getDependencyAssembly(); 13 | 14 | const userService = dependenciesAssembly.userService(); 15 | const identityService = dependenciesAssembly.identityService(); 16 | 17 | describe('POST identities', function () { 18 | it('create correct identity', async () => { 19 | // given 20 | const identityId = 'testCorrectIdentity' + Date.now(); 21 | const userId = 'testCorrectIdentityUid' + Date.now(); 22 | await userService.createUser(userId); 23 | 24 | // when 25 | const res = await identityService.createIdentity(userId, identityId); 26 | 27 | // then 28 | expect(res).toEqual(userId); 29 | }); 30 | 31 | it('create same identity above existing', async () => { 32 | // given 33 | const identityId = 'testExistingIdentity' + Date.now(); 34 | const userId = 'testCorrectIdentityUid' + Date.now(); 35 | await userService.createUser(userId); 36 | await identityService.createIdentity(userId, identityId); 37 | 38 | // when and then 39 | await expectQonversionErrorAsync( 40 | QonversionErrorCode.BackendError, 41 | 'Qonversion API returned an error. Response code 422, message: user already has identity', 42 | async () => { 43 | await identityService.createIdentity(userId, identityId); 44 | }, 45 | ); 46 | }); 47 | 48 | it('create different identity above existing', async () => { 49 | // given 50 | const identityId = 'testExistingIdentity' + Date.now(); 51 | const userId = 'testCorrectIdentityUid' + Date.now(); 52 | await userService.createUser(userId); 53 | await identityService.createIdentity(userId, identityId); 54 | 55 | // when and then 56 | await expectQonversionErrorAsync( 57 | QonversionErrorCode.BackendError, 58 | 'Qonversion API returned an error. Response code 422, message: user already converted', 59 | async () => { 60 | await identityService.createIdentity(userId, identityId + 'another'); 61 | }, 62 | ); 63 | }); 64 | 65 | it('create identity which was already used for another user', async () => { 66 | // given 67 | const identityId = 'testExistingIdentity' + Date.now(); 68 | const identifierUserId = 'testIdentifiedUid' + Date.now(); 69 | await userService.createUser(identifierUserId); 70 | await identityService.createIdentity(identifierUserId, identityId); 71 | 72 | const nonIdentifierUserId = 'testNonIdentifiedUid' + Date.now(); 73 | await userService.createUser(nonIdentifierUserId); 74 | 75 | // when and then 76 | await expectQonversionErrorAsync( 77 | QonversionErrorCode.BackendError, 78 | 'Qonversion API returned an error. Response code 422, message: identity already exists: it\'s linked to another user', 79 | async () => { 80 | await identityService.createIdentity(nonIdentifierUserId, identityId); 81 | }, 82 | ); 83 | }); 84 | 85 | it('create identity for non-existent user', async () => { 86 | // given 87 | const identityId = 'testIdentityForNonExistentUser' + Date.now(); 88 | const nonExistentUserId = 'testNonExistentUid' + Date.now(); 89 | 90 | // when and then 91 | await expectQonversionErrorAsync( 92 | QonversionErrorCode.BackendError, 93 | 'Qonversion API returned an error. Response code 400, message: user not found', 94 | async () => { 95 | await identityService.createIdentity(nonExistentUserId, identityId); 96 | }, 97 | ); 98 | }); 99 | }); 100 | 101 | describe('GET identities', function () { 102 | it('get existing identity', async () => { 103 | // given 104 | const identityId = 'testExistingIdentity' + Date.now(); 105 | const userId = 'testExistingUid' + Date.now(); 106 | await userService.createUser(userId); 107 | await identityService.createIdentity(userId, identityId); 108 | 109 | // when 110 | const res = await identityService.obtainIdentity(identityId); 111 | 112 | // then 113 | expect(res).toEqual(userId); 114 | }); 115 | 116 | it('get non-existent identity', async () => { 117 | // given 118 | const identityId = 'testNonExistentIdentity' + Date.now(); 119 | 120 | // when and then 121 | await expectQonversionErrorAsync( 122 | QonversionErrorCode.IdentityNotFound, 123 | 'User with requested identity not found. Id: ' + identityId, 124 | async () => { 125 | await identityService.obtainIdentity(identityId); 126 | }, 127 | ); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/user/IdentityService.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiInteractor, 3 | RequestConfigurator, NetworkRequest, 4 | ApiResponseError, 5 | ApiResponseSuccess 6 | } from '../../../internal/network'; 7 | import {QonversionError, QonversionErrorCode} from '../../../index'; 8 | import {HTTP_CODE_NOT_FOUND} from '../../../internal/network/constants'; 9 | import {IdentityApi, IdentityService, IdentityServiceImpl} from '../../../internal/user'; 10 | 11 | let requestConfigurator: RequestConfigurator; 12 | let apiInteractor: ApiInteractor; 13 | let identityService: IdentityService; 14 | const testQonversionUserId = 'test qonversion user id'; 15 | const testIdentityUserId = 'test identity user id'; 16 | 17 | const apiPayload: IdentityApi = { 18 | user_id: testQonversionUserId, 19 | }; 20 | const testSuccessfulResponse: ApiResponseSuccess = { 21 | code: 200, 22 | data: apiPayload, 23 | isSuccess: true 24 | }; 25 | const testErrorCode = 500; 26 | const testErrorMessage = 'Test error message'; 27 | const testErrorResponse: ApiResponseError = { 28 | code: testErrorCode, 29 | apiCode: '', 30 | message: testErrorMessage, 31 | type: '', 32 | isSuccess: false, 33 | }; 34 | 35 | beforeEach(() => { 36 | // @ts-ignore 37 | requestConfigurator = {}; 38 | // @ts-ignore 39 | apiInteractor = {}; 40 | 41 | identityService = new IdentityServiceImpl(requestConfigurator, apiInteractor); 42 | }); 43 | 44 | describe('obtainIdentity tests', function () { 45 | // @ts-ignore 46 | const testRequest: NetworkRequest = {a: 'aa'}; 47 | 48 | test('identity successfully obtained', async () => { 49 | // given 50 | requestConfigurator.configureIdentityRequest = jest.fn(() => testRequest); 51 | // @ts-ignore 52 | apiInteractor.execute = jest.fn(async () => testSuccessfulResponse); 53 | 54 | // when 55 | const res = await identityService.obtainIdentity(testIdentityUserId); 56 | 57 | // then 58 | expect(res).toStrictEqual(testQonversionUserId); 59 | expect(requestConfigurator.configureIdentityRequest).toBeCalledWith(testIdentityUserId); 60 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 61 | }); 62 | 63 | test('identity request failed', async () => { 64 | // given 65 | requestConfigurator.configureIdentityRequest = jest.fn(() => testRequest); 66 | apiInteractor.execute = jest.fn(async () => testErrorResponse); 67 | const expError = new QonversionError( 68 | QonversionErrorCode.BackendError, 69 | `Response code ${testErrorCode}, message: ${testErrorMessage}`, 70 | ); 71 | 72 | // when and then 73 | await expect(() => identityService.obtainIdentity(testIdentityUserId)).rejects.toThrow(expError); 74 | expect(requestConfigurator.configureIdentityRequest).toBeCalledWith(testIdentityUserId); 75 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 76 | }); 77 | 78 | test('identity does not exist', async () => { 79 | // given 80 | const testUserNotFoundResponse: ApiResponseError = { 81 | code: HTTP_CODE_NOT_FOUND, 82 | apiCode: '', 83 | message: testErrorMessage, 84 | type: '', 85 | isSuccess: false, 86 | }; 87 | requestConfigurator.configureIdentityRequest = jest.fn(() => testRequest); 88 | apiInteractor.execute = jest.fn(async () => testUserNotFoundResponse); 89 | const expError = new QonversionError( 90 | QonversionErrorCode.IdentityNotFound, 91 | `Id: ${testIdentityUserId}`, 92 | ); 93 | 94 | // when and then 95 | await expect(() => identityService.obtainIdentity(testIdentityUserId)).rejects.toThrow(expError); 96 | expect(requestConfigurator.configureIdentityRequest).toBeCalledWith(testIdentityUserId); 97 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 98 | }); 99 | }); 100 | 101 | describe('createIdentity tests', function () { 102 | // @ts-ignore 103 | const testRequest: NetworkRequest = {a: 'aa'}; 104 | 105 | test('identity successfully received', async () => { 106 | // given 107 | requestConfigurator.configureCreateIdentityRequest = jest.fn(() => testRequest); 108 | // @ts-ignore 109 | apiInteractor.execute = jest.fn(async () => testSuccessfulResponse); 110 | 111 | // when 112 | const res = await identityService.createIdentity(testQonversionUserId, testIdentityUserId); 113 | 114 | // then 115 | expect(res).toStrictEqual(testQonversionUserId); 116 | expect(requestConfigurator.configureCreateIdentityRequest).toBeCalledWith(testQonversionUserId, testIdentityUserId); 117 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 118 | }); 119 | 120 | test('user request failed', async () => { 121 | // given 122 | requestConfigurator.configureCreateIdentityRequest = jest.fn(() => testRequest); 123 | apiInteractor.execute = jest.fn(async () => testErrorResponse); 124 | const expError = new QonversionError( 125 | QonversionErrorCode.BackendError, 126 | `Response code ${testErrorCode}, message: ${testErrorMessage}`, 127 | ); 128 | 129 | // when and then 130 | await expect(() => identityService.createIdentity(testQonversionUserId, testIdentityUserId)).rejects.toThrow(expError); 131 | expect(requestConfigurator.configureCreateIdentityRequest).toBeCalledWith(testQonversionUserId, testIdentityUserId); 132 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/user/UserService.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiInteractor, 3 | RequestConfigurator, 4 | NetworkRequest, 5 | ApiResponseError, 6 | ApiResponseSuccess 7 | } from '../../../internal/network'; 8 | import {UserApi, UserService, UserServiceImpl} from '../../../internal/user'; 9 | import {Environment, QonversionError, QonversionErrorCode, User} from '../../../index'; 10 | import {HTTP_CODE_NOT_FOUND} from '../../../internal/network/constants'; 11 | import {PrimaryConfig} from '../../../types'; 12 | import {PrimaryConfigProvider} from '../../../internal'; 13 | 14 | let primaryConfig: PrimaryConfig; 15 | let requestConfigurator: RequestConfigurator; 16 | let apiInteractor: ApiInteractor; 17 | let userService: UserService; 18 | const testUserId = 'test user id'; 19 | 20 | const apiPayload: UserApi = { 21 | created: 0, 22 | environment: 'prod', 23 | id: testUserId, 24 | identity_id: 'some identity', 25 | }; 26 | const testSuccessfulResponse: ApiResponseSuccess = { 27 | code: 200, 28 | data: apiPayload, 29 | isSuccess: true 30 | }; 31 | const testErrorCode = 500; 32 | const testErrorMessage = 'Test error message'; 33 | const testErrorResponse: ApiResponseError = { 34 | code: testErrorCode, 35 | apiCode: '', 36 | message: testErrorMessage, 37 | type: '', 38 | isSuccess: false, 39 | }; 40 | const expRes: User = { 41 | created: 0, 42 | environment: 'prod', 43 | id: testUserId, 44 | identityId: 'some identity', 45 | }; 46 | const testEnvironment = Environment.Sandbox; 47 | 48 | beforeEach(() => { 49 | // @ts-ignore 50 | primaryConfig = { 51 | environment: testEnvironment, 52 | }; 53 | const primaryConfigProvider: PrimaryConfigProvider = { 54 | getPrimaryConfig: () => primaryConfig, 55 | }; 56 | // @ts-ignore 57 | requestConfigurator = {}; 58 | // @ts-ignore 59 | apiInteractor = {}; 60 | 61 | userService = new UserServiceImpl(primaryConfigProvider, requestConfigurator, apiInteractor); 62 | }); 63 | 64 | describe('getUser tests', function () { 65 | // @ts-ignore 66 | const testRequest: NetworkRequest = {a: 'aa'}; 67 | 68 | test('user successfully received', async () => { 69 | // given 70 | requestConfigurator.configureUserRequest = jest.fn(() => testRequest); 71 | // @ts-ignore 72 | apiInteractor.execute = jest.fn(async () => testSuccessfulResponse); 73 | 74 | // when 75 | const res = await userService.getUser(testUserId); 76 | 77 | // then 78 | expect(res).toStrictEqual(expRes); 79 | expect(requestConfigurator.configureUserRequest).toBeCalledWith(testUserId); 80 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 81 | }); 82 | 83 | test('user request failed', async () => { 84 | // given 85 | requestConfigurator.configureUserRequest = jest.fn(() => testRequest); 86 | apiInteractor.execute = jest.fn(async () => testErrorResponse); 87 | const expError = new QonversionError( 88 | QonversionErrorCode.BackendError, 89 | `Response code ${testErrorCode}, message: ${testErrorMessage}`, 90 | ); 91 | 92 | // when and then 93 | await expect(() => userService.getUser(testUserId)).rejects.toThrow(expError); 94 | expect(requestConfigurator.configureUserRequest).toBeCalledWith(testUserId); 95 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 96 | }); 97 | 98 | test('user does not exist', async () => { 99 | // given 100 | const testUserNotFoundResponse: ApiResponseError = { 101 | code: HTTP_CODE_NOT_FOUND, 102 | apiCode: '', 103 | message: testErrorMessage, 104 | type: '', 105 | isSuccess: false, 106 | }; 107 | requestConfigurator.configureUserRequest = jest.fn(() => testRequest); 108 | apiInteractor.execute = jest.fn(async () => testUserNotFoundResponse); 109 | const expError = new QonversionError( 110 | QonversionErrorCode.UserNotFound, 111 | `Id: ${testUserId}`, 112 | ); 113 | 114 | // when and then 115 | await expect(() => userService.getUser(testUserId)).rejects.toThrow(expError); 116 | expect(requestConfigurator.configureUserRequest).toBeCalledWith(testUserId); 117 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 118 | }); 119 | }); 120 | 121 | describe('createUser tests', function () { 122 | // @ts-ignore 123 | const testRequest: NetworkRequest = {a: 'aa'}; 124 | 125 | test('user successfully received', async () => { 126 | // given 127 | requestConfigurator.configureCreateUserRequest = jest.fn(() => testRequest); 128 | // @ts-ignore 129 | apiInteractor.execute = jest.fn(async () => testSuccessfulResponse); 130 | 131 | // when 132 | const res = await userService.createUser(testUserId); 133 | 134 | // then 135 | expect(res).toStrictEqual(expRes); 136 | expect(requestConfigurator.configureCreateUserRequest).toBeCalledWith(testUserId, testEnvironment); 137 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 138 | }); 139 | 140 | test('user request failed', async () => { 141 | // given 142 | requestConfigurator.configureCreateUserRequest = jest.fn(() => testRequest); 143 | apiInteractor.execute = jest.fn(async () => testErrorResponse); 144 | const expError = new QonversionError( 145 | QonversionErrorCode.BackendError, 146 | `Response code ${testErrorCode}, message: ${testErrorMessage}`, 147 | ); 148 | 149 | // when and then 150 | await expect(() => userService.createUser(testUserId)).rejects.toThrow(expError); 151 | expect(requestConfigurator.configureCreateUserRequest).toBeCalledWith(testUserId, testEnvironment); 152 | expect(apiInteractor.execute).toBeCalledWith(testRequest); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/common/LocalStorage.test.ts: -------------------------------------------------------------------------------- 1 | import {LocalStorage, LocalStorageImpl} from '../../../internal/common'; 2 | 3 | let savedJSON: JSON; 4 | 5 | beforeAll(() => { 6 | savedJSON = JSON; 7 | }); 8 | 9 | afterAll(() => { 10 | JSON = savedJSON; 11 | }); 12 | 13 | describe('local storage tests', () => { 14 | let myLocalStorage: LocalStorage; 15 | const testKey = 'test key'; 16 | const testValue = 'test value'; 17 | 18 | beforeEach(() => { 19 | // @ts-ignore 20 | // noinspection JSConstantReassignment 21 | global.localStorage = { 22 | getItem: jest.fn(() => testValue), 23 | setItem: jest.fn(), 24 | removeItem: jest.fn(), 25 | }; 26 | JSON.parse = jest.fn(); 27 | JSON.stringify = jest.fn(); 28 | 29 | myLocalStorage = new LocalStorageImpl(); 30 | }); 31 | 32 | test('put string', () => { 33 | // given 34 | const testValue = 'test value'; 35 | 36 | // when 37 | myLocalStorage.putString(testKey, testValue); 38 | 39 | // then 40 | expect(localStorage.setItem).toBeCalledWith(testKey, testValue); 41 | }); 42 | 43 | test('put number', () => { 44 | // given 45 | const testValue = 5; 46 | const strValue = '5'; 47 | 48 | // when 49 | myLocalStorage.putNumber(testKey, testValue); 50 | 51 | // then 52 | expect(localStorage.setItem).toBeCalledWith(testKey, strValue); 53 | }); 54 | 55 | test('put object', () => { 56 | // given 57 | const testValue = {a: 'a', b: 'b'}; 58 | const strValue = 'object as string' 59 | JSON.stringify = jest.fn(() => strValue); 60 | 61 | // when 62 | myLocalStorage.putObject(testKey, testValue); 63 | 64 | // then 65 | expect(JSON.stringify).toBeCalledWith(testValue); 66 | expect(localStorage.setItem).toBeCalledWith(testKey, strValue); 67 | }); 68 | 69 | test('put object faces error', () => { 70 | // given 71 | const testValue = {a: 'a', b: 'b'}; 72 | JSON.stringify = jest.fn(() => {throw new Error()}); 73 | 74 | // when 75 | myLocalStorage.putObject(testKey, testValue); 76 | 77 | // then 78 | expect(JSON.stringify).toBeCalledWith(testValue); 79 | expect(localStorage.setItem).not.toBeCalled(); 80 | }); 81 | 82 | test('get string', () => { 83 | // given 84 | 85 | // when 86 | const res = myLocalStorage.getString(testKey); 87 | 88 | // then 89 | expect(res).toBe(testValue); 90 | expect(localStorage.getItem).toBeCalledWith(testKey); 91 | }); 92 | 93 | test('get int', () => { 94 | // given 95 | const expRes = 10; 96 | localStorage.getItem = jest.fn(() => expRes.toString()); 97 | 98 | // when 99 | const res = myLocalStorage.getInt(testKey); 100 | 101 | // then 102 | expect(res).toBe(expRes); 103 | expect(localStorage.getItem).toBeCalledWith(testKey); 104 | }); 105 | 106 | test('get int from incorrect string', () => { 107 | // given 108 | 109 | // when 110 | const res = myLocalStorage.getInt(testKey); 111 | 112 | // then 113 | expect(res).toBe(NaN); 114 | expect(localStorage.getItem).toBeCalledWith(testKey); 115 | }); 116 | 117 | test('get int from undefined string', () => { 118 | // given 119 | localStorage.getItem = jest.fn(() => null); 120 | 121 | // when 122 | const res = myLocalStorage.getInt(testKey); 123 | 124 | // then 125 | expect(res).toBeUndefined(); 126 | expect(localStorage.getItem).toBeCalledWith(testKey); 127 | }); 128 | 129 | test('get float', () => { 130 | // given 131 | const expRes = 10.52; 132 | localStorage.getItem = jest.fn(() => expRes.toString()); 133 | 134 | // when 135 | const res = myLocalStorage.getFloat(testKey); 136 | 137 | // then 138 | expect(res).toBe(expRes); 139 | expect(localStorage.getItem).toBeCalledWith(testKey); 140 | }); 141 | 142 | test('get float from incorrect string', () => { 143 | // given 144 | 145 | // when 146 | const res = myLocalStorage.getFloat(testKey); 147 | 148 | // then 149 | expect(res).toBe(NaN); 150 | expect(localStorage.getItem).toBeCalledWith(testKey); 151 | }); 152 | 153 | test('get float from undefined string', () => { 154 | // given 155 | localStorage.getItem = jest.fn(() => null); 156 | 157 | // when 158 | const res = myLocalStorage.getFloat(testKey); 159 | 160 | // then 161 | expect(res).toBeUndefined(); 162 | expect(localStorage.getItem).toBeCalledWith(testKey); 163 | }); 164 | 165 | test('get object', () => { 166 | // given 167 | const expRes = {a: 'a', b: 'b'}; 168 | JSON.parse = jest.fn(() => expRes); 169 | 170 | // when 171 | const res = myLocalStorage.getObject(testKey); 172 | 173 | // then 174 | expect(res).toBe(expRes); 175 | expect(localStorage.getItem).toBeCalledWith(testKey); 176 | expect(JSON.parse).toBeCalledWith(testValue); 177 | }); 178 | 179 | test('get object from incorrect string', () => { 180 | // given 181 | JSON.parse = jest.fn(() => {throw new Error()}); 182 | 183 | // when 184 | const res = myLocalStorage.getObject(testKey); 185 | 186 | // then 187 | expect(res).toBeUndefined(); 188 | expect(localStorage.getItem).toBeCalledWith(testKey); 189 | expect(JSON.parse).toBeCalledWith(testValue); 190 | }); 191 | 192 | test('get object from undefined string', () => { 193 | // given 194 | localStorage.getItem = jest.fn(() => null); 195 | 196 | // when 197 | const res = myLocalStorage.getObject(testKey); 198 | 199 | // then 200 | expect(res).toBeUndefined(); 201 | expect(localStorage.getItem).toBeCalledWith(testKey); 202 | expect(JSON.parse).not.toBeCalled(); 203 | }); 204 | 205 | test('remove value', () => { 206 | // given 207 | 208 | // when 209 | myLocalStorage.remove(testKey); 210 | 211 | // then 212 | expect(localStorage.removeItem).toBeCalledWith(testKey); 213 | }); 214 | }); -------------------------------------------------------------------------------- /sdk/src/internal/di/DependenciesAssembly.ts: -------------------------------------------------------------------------------- 1 | import {ControllersAssembly, MiscAssembly, NetworkAssembly, ServicesAssembly, StorageAssembly} from './types'; 2 | import {InternalConfig} from '../InternalConfig'; 3 | import {MiscAssemblyImpl} from './MiscAssembly'; 4 | import {NetworkAssemblyImpl} from './NetworkAssembly'; 5 | import {ServicesAssemblyImpl} from './ServicesAssembly'; 6 | import {ControllersAssemblyImpl} from './ControllersAssembly'; 7 | import {StorageAssemblyImpl} from './StorageAssembly'; 8 | import {ApiInteractor, HeaderBuilder, NetworkClient, RequestConfigurator, RetryDelayCalculator} from '../network'; 9 | import { 10 | IdentityService, 11 | UserDataProvider, 12 | UserController, 13 | UserDataStorage, 14 | UserIdGenerator, 15 | UserService, 16 | } from '../user'; 17 | import {Logger} from '../logger'; 18 | import {LocalStorage} from '../common'; 19 | import {UserPropertiesController, UserPropertiesService, UserPropertiesStorage} from '../userProperties'; 20 | import {DelayedWorker} from '../utils/DelayedWorker'; 21 | import {EntitlementsController, EntitlementsService} from '../entitlements'; 22 | import {PurchasesController, PurchasesService} from '../purchases'; 23 | 24 | export class DependenciesAssembly implements MiscAssembly, NetworkAssembly, ServicesAssembly, ControllersAssembly, StorageAssembly { 25 | private readonly networkAssembly: NetworkAssembly; 26 | private readonly miscAssembly: MiscAssembly; 27 | private readonly servicesAssembly: ServicesAssembly; 28 | private readonly controllersAssembly: ControllersAssembly; 29 | private readonly storageAssembly: StorageAssembly; 30 | 31 | constructor( 32 | networkAssembly: NetworkAssembly, 33 | miscAssembly: MiscAssembly, 34 | servicesAssembly: ServicesAssembly, 35 | controllersAssembly: ControllersAssembly, 36 | storageAssembly: StorageAssembly, 37 | ) { 38 | this.networkAssembly = networkAssembly; 39 | this.miscAssembly = miscAssembly; 40 | this.servicesAssembly = servicesAssembly; 41 | this.controllersAssembly = controllersAssembly; 42 | this.storageAssembly = storageAssembly; 43 | }; 44 | 45 | logger(): Logger { 46 | return this.miscAssembly.logger(); 47 | } 48 | 49 | exponentialDelayCalculator(): RetryDelayCalculator { 50 | return this.miscAssembly.exponentialDelayCalculator(); 51 | } 52 | 53 | delayedWorker(): DelayedWorker { 54 | return this.miscAssembly.delayedWorker(); 55 | } 56 | 57 | userIdGenerator(): UserIdGenerator { 58 | return this.miscAssembly.userIdGenerator(); 59 | } 60 | 61 | exponentialApiInteractor(): ApiInteractor { 62 | return this.networkAssembly.exponentialApiInteractor(); 63 | } 64 | 65 | infiniteExponentialApiInteractor(): ApiInteractor { 66 | return this.networkAssembly.infiniteExponentialApiInteractor(); 67 | } 68 | 69 | headerBuilder(): HeaderBuilder { 70 | return this.networkAssembly.headerBuilder(); 71 | } 72 | 73 | networkClient(): NetworkClient { 74 | return this.networkAssembly.networkClient(); 75 | } 76 | 77 | requestConfigurator(): RequestConfigurator { 78 | return this.networkAssembly.requestConfigurator(); 79 | } 80 | 81 | localStorage(): LocalStorage { 82 | return this.storageAssembly.localStorage(); 83 | } 84 | 85 | userDataProvider(): UserDataProvider { 86 | return this.storageAssembly.userDataProvider(); 87 | } 88 | 89 | userDataStorage(): UserDataStorage { 90 | return this.storageAssembly.userDataStorage(); 91 | } 92 | 93 | sentUserPropertiesStorage(): UserPropertiesStorage { 94 | return this.storageAssembly.sentUserPropertiesStorage(); 95 | } 96 | 97 | pendingUserPropertiesStorage(): UserPropertiesStorage { 98 | return this.storageAssembly.pendingUserPropertiesStorage(); 99 | } 100 | 101 | userPropertiesService(): UserPropertiesService { 102 | return this.servicesAssembly.userPropertiesService(); 103 | } 104 | 105 | userService(): UserService { 106 | return this.servicesAssembly.userService(); 107 | } 108 | 109 | userServiceDecorator(): UserService { 110 | return this.servicesAssembly.userServiceDecorator(); 111 | } 112 | 113 | identityService(): IdentityService { 114 | return this.servicesAssembly.identityService(); 115 | } 116 | 117 | entitlementsService(): EntitlementsService { 118 | return this.servicesAssembly.entitlementsService(); 119 | } 120 | 121 | purchasesService(): PurchasesService { 122 | return this.servicesAssembly.purchasesService(); 123 | } 124 | 125 | userPropertiesController(): UserPropertiesController { 126 | return this.controllersAssembly.userPropertiesController(); 127 | } 128 | 129 | userController(): UserController { 130 | return this.controllersAssembly.userController(); 131 | } 132 | 133 | entitlementsController(): EntitlementsController { 134 | return this.controllersAssembly.entitlementsController(); 135 | } 136 | 137 | purchasesController(): PurchasesController { 138 | return this.controllersAssembly.purchasesController(); 139 | } 140 | } 141 | 142 | export class DependenciesAssemblyBuilder { 143 | private readonly internalConfig: InternalConfig; 144 | 145 | constructor(internalConfig: InternalConfig) { 146 | this.internalConfig = internalConfig; 147 | }; 148 | 149 | build(): DependenciesAssembly { 150 | const miscAssembly = new MiscAssemblyImpl(this.internalConfig); 151 | const storageAssembly = new StorageAssemblyImpl(); 152 | const networkAssembly = new NetworkAssemblyImpl(this.internalConfig, storageAssembly, miscAssembly); 153 | const servicesAssembly = new ServicesAssemblyImpl(this.internalConfig, networkAssembly); 154 | const controllersAssembly = new ControllersAssemblyImpl(miscAssembly, storageAssembly, servicesAssembly); 155 | 156 | return new DependenciesAssembly( 157 | networkAssembly, miscAssembly, servicesAssembly, controllersAssembly, storageAssembly 158 | ); 159 | }; 160 | } 161 | -------------------------------------------------------------------------------- /sdk/src/__tests__/internal/userProperties/UserPropertiesStorage.test.ts: -------------------------------------------------------------------------------- 1 | import {UserPropertiesStorageImpl} from '../../../internal/userProperties'; 2 | import {LocalStorage, LocalStorageImpl} from '../../../internal/common'; 3 | 4 | describe('UserPropertiesStorage tests', () => { 5 | let userPropertiesStorage: UserPropertiesStorageImpl; 6 | const mockLocalStorage: LocalStorage = new LocalStorageImpl(); 7 | const testStorageKey = 'storage key'; 8 | const initialProperties = {a: 'a'}; 9 | let savePropertiesSpy: jest.SpyInstance; 10 | 11 | beforeEach(() => { 12 | // @ts-ignore 13 | mockLocalStorage.getObject = jest.fn(() => initialProperties); 14 | mockLocalStorage.putObject = jest.fn(); 15 | userPropertiesStorage = new UserPropertiesStorageImpl(mockLocalStorage, testStorageKey); 16 | // @ts-ignore 17 | savePropertiesSpy = jest.spyOn(userPropertiesStorage, 'saveProperties'); 18 | }); 19 | 20 | test('constructor', () => { 21 | // given 22 | 23 | // when 24 | // done in beforeEach 25 | 26 | // then 27 | expect(userPropertiesStorage['properties']).toStrictEqual(initialProperties); 28 | expect(mockLocalStorage.getObject).toBeCalledWith(testStorageKey); 29 | }); 30 | 31 | test('get properties', () => { 32 | // given 33 | const testProperties = {a: 'a', b: 'b'}; 34 | userPropertiesStorage['properties'] = testProperties; 35 | 36 | // when 37 | const res = userPropertiesStorage.getProperties(); 38 | 39 | // then 40 | expect(res).toStrictEqual(testProperties); 41 | }); 42 | 43 | test('save properties', () => { 44 | // given 45 | 46 | // when 47 | userPropertiesStorage['saveProperties'](); 48 | 49 | // then 50 | expect(mockLocalStorage.putObject).toBeCalledWith(testStorageKey, initialProperties); 51 | }); 52 | 53 | test('add one', () => { 54 | // given 55 | const key = 'test key'; 56 | const value = 'test value'; 57 | const expProperties = { 58 | ...initialProperties, 59 | [key]: value 60 | }; 61 | 62 | // when 63 | userPropertiesStorage.addOne(key, value); 64 | 65 | // then 66 | expect(userPropertiesStorage['properties']).toStrictEqual(expProperties); 67 | expect(savePropertiesSpy).toBeCalled(); 68 | }); 69 | 70 | test('add several', () => { 71 | // given 72 | const newProperties = { 73 | b: 'b', 74 | c: 'c', 75 | }; 76 | const expProperties = { 77 | ...initialProperties, 78 | ...newProperties, 79 | }; 80 | 81 | // when 82 | userPropertiesStorage.add(newProperties); 83 | 84 | // then 85 | expect(userPropertiesStorage['properties']).toStrictEqual(expProperties); 86 | expect(savePropertiesSpy).toBeCalled(); 87 | }); 88 | 89 | test('delete one', () => { 90 | // given 91 | userPropertiesStorage['properties'] = { 92 | b: 'bb', 93 | c: 'cc', 94 | }; 95 | const expProperties = { 96 | c: 'cc', 97 | }; 98 | 99 | // when 100 | userPropertiesStorage.deleteOne('b', 'bb'); 101 | 102 | // then 103 | expect(userPropertiesStorage['properties']).toStrictEqual(expProperties); 104 | expect(savePropertiesSpy).toBeCalled(); 105 | }); 106 | 107 | test('delete one with changed value', () => { 108 | // given 109 | userPropertiesStorage['properties'] = { 110 | b: 'bb', 111 | c: 'cc', 112 | }; 113 | const expProperties = { 114 | b: 'bb', 115 | c: 'cc', 116 | }; 117 | 118 | // when 119 | userPropertiesStorage.deleteOne('b', 'cc'); 120 | 121 | // then 122 | expect(userPropertiesStorage['properties']).toStrictEqual(expProperties); 123 | expect(savePropertiesSpy).not.toBeCalled(); 124 | }); 125 | 126 | test('delete several', () => { 127 | // given 128 | const deletingProperties = { 129 | a: 'aa', 130 | b: 'bb', 131 | }; 132 | const expProperties = { 133 | c: 'cc', 134 | }; 135 | userPropertiesStorage['properties'] = { 136 | ...deletingProperties, 137 | ...expProperties, 138 | }; 139 | 140 | // when 141 | userPropertiesStorage.delete(deletingProperties); 142 | 143 | // then 144 | expect(userPropertiesStorage['properties']).toStrictEqual(expProperties); 145 | expect(savePropertiesSpy).toBeCalled(); 146 | }); 147 | 148 | test('delete several with some changed values', () => { 149 | // given 150 | const deletingProperties = { 151 | a: 'aa', 152 | b: 'cc', 153 | }; 154 | const expProperties = { 155 | b: 'bb', 156 | c: 'cc', 157 | }; 158 | userPropertiesStorage['properties'] = { 159 | a: 'aa', 160 | b: 'bb', 161 | c: 'cc', 162 | }; 163 | 164 | // when 165 | userPropertiesStorage.delete(deletingProperties); 166 | 167 | // then 168 | expect(userPropertiesStorage['properties']).toStrictEqual(expProperties); 169 | expect(savePropertiesSpy).toBeCalled(); 170 | }); 171 | 172 | test('delete empty records', () => { 173 | // given 174 | const deletingProperties = {}; 175 | const expProperties = { 176 | a: 'aa', 177 | b: 'bb', 178 | c: 'cc', 179 | }; 180 | userPropertiesStorage['properties'] = { 181 | a: 'aa', 182 | b: 'bb', 183 | c: 'cc', 184 | }; 185 | 186 | // when 187 | userPropertiesStorage.delete(deletingProperties); 188 | 189 | // then 190 | expect(userPropertiesStorage['properties']).toStrictEqual(expProperties); 191 | expect(savePropertiesSpy).toBeCalled(); 192 | }); 193 | 194 | test('clear', () => { 195 | // given 196 | userPropertiesStorage['properties'] = { 197 | a: 'aa', 198 | b: 'bb', 199 | c: 'cc', 200 | }; 201 | 202 | // when 203 | userPropertiesStorage.clear(); 204 | 205 | // then 206 | expect(userPropertiesStorage['properties']).toStrictEqual({}); 207 | expect(savePropertiesSpy).toBeCalled(); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /sdk/src/internal/user/UserController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IdentityService, 3 | UserChangedListener, 4 | UserController, 5 | UserDataStorage, 6 | UserIdGenerator, 7 | UserService 8 | } from './types'; 9 | import {User} from '../../dto/User'; 10 | import {Logger} from '../logger'; 11 | import {QonversionError} from '../../exception/QonversionError'; 12 | import {QonversionErrorCode} from '../../exception/QonversionErrorCode'; 13 | 14 | export class UserControllerImpl implements UserController { 15 | private readonly userService: UserService; 16 | private readonly identityService: IdentityService; 17 | private readonly userDataStorage: UserDataStorage; 18 | private readonly userIdGenerator: UserIdGenerator; 19 | private readonly logger: Logger; 20 | private userChangedListeners: UserChangedListener[] = []; 21 | 22 | constructor( 23 | userService: UserService, 24 | identityService: IdentityService, 25 | userDataStorage: UserDataStorage, 26 | userIdGenerator: UserIdGenerator, 27 | logger: Logger, 28 | ) { 29 | this.userService = userService; 30 | this.identityService = identityService; 31 | this.userDataStorage = userDataStorage; 32 | this.userIdGenerator = userIdGenerator; 33 | this.logger = logger; 34 | 35 | const existingUserId = userDataStorage.getOriginalUserId(); 36 | if (!existingUserId) { 37 | this.logger.verbose('User doesn\'t exist, creating new one...'); 38 | this.createUser() 39 | .then(() => this.logger.info('New user created on initialization')) 40 | .catch(error => this.logger.error('Failed to create new user on initialization', error)); 41 | } 42 | } 43 | 44 | async getUser(): Promise { 45 | try { 46 | const userId = this.userDataStorage.requireOriginalUserId(); 47 | this.logger.verbose('Sending user request', {userId}); 48 | const apiUser = await this.userService.getUser(userId); 49 | this.logger.info('User info was successfully received from API', apiUser); 50 | return apiUser; 51 | } catch (error) { 52 | this.logger.error('Failed to get User from API', error) 53 | throw error; 54 | } 55 | } 56 | 57 | async identify(identityId: string): Promise { 58 | if (identityId == this.userDataStorage.getIdentityUserId()) { 59 | this.logger.verbose('Current user has the same identity id', {identityId}); 60 | return; 61 | } 62 | 63 | try { 64 | this.logger.verbose('Checking for existing user with the given identity id', {identityId}); 65 | const newOriginalId = await this.identityService.obtainIdentity(identityId); 66 | this.handleSuccessfulIdentity(newOriginalId, identityId); 67 | } catch (error) { 68 | if (error instanceof QonversionError && error.code == QonversionErrorCode.IdentityNotFound) { 69 | const originalId = this.userDataStorage.requireOriginalUserId(); 70 | 71 | try { 72 | this.logger.verbose('No user found with the given identity id, linking current one', {userId: originalId, identityId}); 73 | const newOriginalId = await this.identityService.createIdentity(originalId, identityId); 74 | this.handleSuccessfulIdentity(newOriginalId, identityId); 75 | } catch (secondaryError) { 76 | this.logger.error(`Failed to create user identity for id ${identityId}`, secondaryError); 77 | throw secondaryError; 78 | } 79 | } else { 80 | this.logger.error(`Failed to identify user with id ${identityId}`, error); 81 | throw error; 82 | } 83 | } 84 | } 85 | 86 | async logout(): Promise { 87 | if (!this.userDataStorage.getIdentityUserId()) { 88 | this.logger.verbose('No user is identified, no need to logout'); 89 | return; 90 | } 91 | 92 | try { 93 | await this.createUser(); 94 | this.logger.info('Logout is completed. A new user is successfully created.'); 95 | } catch (error) { 96 | this.logger.error('Failed to create new user after logout.', error); 97 | throw error; 98 | } 99 | } 100 | 101 | async createUser(): Promise { 102 | const oldOriginalId = this.userDataStorage.getOriginalUserId(); 103 | const oldIdentityId = this.userDataStorage.getIdentityUserId(); 104 | 105 | this.userDataStorage.clearIdentityUserId(); 106 | const newOriginalId = this.userIdGenerator.generate(); 107 | this.userDataStorage.setOriginalUserId(newOriginalId); 108 | 109 | this.logger.verbose('Creating new user', {userId: newOriginalId}); 110 | const user = this.userService.createUser(newOriginalId); 111 | 112 | this.fireUserChangedEvent(newOriginalId, oldOriginalId, oldIdentityId); 113 | 114 | return user; 115 | } 116 | 117 | subscribeOnUserChanges(listener: UserChangedListener): void { 118 | this.userChangedListeners.push(listener); 119 | } 120 | 121 | private handleSuccessfulIdentity(originalId: string, identityId: string) { 122 | const oldOriginalId = this.userDataStorage.getOriginalUserId(); 123 | const oldIdentityId = this.userDataStorage.getIdentityUserId(); 124 | 125 | this.logger.info(`User with id ${identityId} is successfully identified.`); 126 | 127 | this.userDataStorage.setOriginalUserId(originalId); 128 | this.userDataStorage.setIdentityUserId(identityId); 129 | 130 | this.fireUserChangedEvent(originalId, oldOriginalId, oldIdentityId); 131 | } 132 | 133 | private fireUserChangedEvent(newUserOriginalId: string, oldUserOriginalId?: string, oldUserIdentityId?: string): void { 134 | if (oldUserOriginalId != newUserOriginalId) { 135 | this.logger.verbose( 136 | `Current user has changed. Notifying ${this.userChangedListeners.length} listeners.`, 137 | {newUserOriginalId, oldUserOriginalId, oldUserIdentityId} 138 | ); 139 | 140 | this.userChangedListeners.forEach(listener => listener.onUserChanged(newUserOriginalId, oldUserOriginalId, oldUserIdentityId)) 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Qonversion 3 |

4 | 5 | Qonversion - In-app subscription monetization: implement subscriptions and grow your app’s revenue with A/B experiments 6 | 7 | * In-app subscription management SDK 8 | * API and webhooks to make your subscription data available where you need it 9 | * Seamless Stripe integration to enable cross-platform access management 10 | * Subscribers CRM with user-level transactions 11 | * Instant access to real-time subscription analytics 12 | * Built-in A/B experiments for subscription business model 13 | 14 |

15 | 16 | 17 |

18 | 19 | [![Release](https://img.shields.io/github/release/qonversion/web-sdk.svg?style=flat)](https://github.com/qonversion/web-sdk/releases) 20 | [![MIT License](http://img.shields.io/cocoapods/l/Qonversion.svg?style=flat)](https://qonversion.io) 21 | 22 | 23 | ## In-App Subscription Implementation & Management 24 | 25 |

26 | 27 | 28 |

29 | 30 | 1. Qonversion SDK provides three simple methods to manage subscriptions: 31 | * Get in-app product details 32 | * Make purchases 33 | * Check subscription status to manage premium access 34 | 2. Qonversion communicates with Apple or Google platforms both through SDK and server-side to process native in-app payments and keep subscription statuses up to date. 35 | 3. You can use Qonversion webhooks and API in addition to SDK to get user-level data where you need it. 36 | 37 | See the [quick start guide documentation](https://documentation.qonversion.io/docs/quickstart). 38 | 39 | ## Analytics 40 | 41 | Qonversion provides advanced subscription analytics out-of-the-box. You can monitor real-time metrics from new users and trial-to-paid conversions to revenue, MRR, ARR, cohort retention and more. Understand your customers and make better decisions with precise subscription analytics. 42 | 43 |

44 | 45 | 46 |

47 | 48 | 49 | ## Integrations 50 | 51 | Send user-level subscription data to your favorite platforms. 52 | 53 | * Amplitude 54 | * Mixpanel 55 | * Appsflyer 56 | * Adjust 57 | * Singular 58 | * CleverTap 59 | * [All other integrations here](qonversion.io/integrations) 60 | 61 |

62 | 63 | 64 |

65 | 66 | ## Why Qonversion? 67 | 68 | * **No headaches with Stripe, Apple's StoreKit & Google Billing.** Qonversion provides simple methods to handle Stripe, Apple StoreKit & Google Billing purchase flow. 69 | * **Receipt validation.** Qonversion validates user receipts with Apple, Google and Stripe to provide 100% accurate purchase information and subscription statuses. It also prevents unauthorized access to the premium features of your app. 70 | * **Track and increase your revenue.** Qonversion provides detailed real-time revenue analytics including cohort analysis, trial conversion rates, country segmentation, and much more. 71 | * **Integrations with the leading mobile platforms.** Qonversion allows sending data to platforms like AppsFlyer, Adjust, Branch, Tenjin, Facebook Ads, Amplitude, Mixpanel, and many others. 72 | * **Change promoted in-app products.** Change promoted in-app products anytime without app releases. 73 | * **A/B test** and identify winning in-app purchases, subscriptions or paywals. 74 | * **Cross-device and cross-platform access management.** If you provide user authorization in your app, you can easily set Qonversion to provide premium access to authorized users across devices and operating systems. 75 | * **SDK caches the data.** Qonversion SDK caches purchase data including in-app products and entitlements, so the user experience is not affected even with the slow or interrupting network connection. 76 | * **Webhooks.** You can easily send all of the data to your server with Qonversion webhooks. 77 | * **Customer support.** You can always reach out to our customer support and get the help required. 78 | 79 | Convinced? Let's go! 80 | 81 | ## Documentation 82 | 83 | Check the [full documentation](https://documentation.qonversion.io/docs/quickstart) to learn about implementation details and available features. 84 | 85 | #### Help us improve the documentation 86 | 87 | Whether you’re a core user or trying it out for the first time, you can make a valuable contribution to Qonversion by improving the documentation. Help us by: 88 | 89 | * sending us feedback about something you thought was confusing or simply missing 90 | * sending us a pull request via GitHub 91 | * suggesting better wording or ways of explaining certain topics in the [Qonversion documentation](http://documentation.qonversion.io). Use `SUGGEST EDITS` button in the top right corner. 92 | 93 | ## Contributing 94 | 95 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 96 | 97 | 1. Fork the Project 98 | 2. Create your Feature Branch (`git checkout -b feature/SuperFeature`) 99 | 3. Commit your Changes. Use small commits with separate logic. (`git commit -m 'Add some super feature'`) 100 | 4. Push to the Branch (`git push origin feature/SuperFeature`) 101 | 5. Open a Pull Request 102 | 103 | 104 | ## Have a question? 105 | 106 | Contact us via [issues on GitHub](https://github.com/qonversion/web-sdk/issues) or [ask a question](https://documentation.qonversion.io/discuss-new) on the site. 107 | 108 | ## License 109 | 110 | Qonversion SDK is available under the MIT license. --------------------------------------------------------------------------------