├── .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 | [](https://github.com/qonversion/web-sdk/releases)
20 | [](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.
--------------------------------------------------------------------------------