Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Wrap your app's root component with ContentpassSdkProvider. The provider require
- `planId` - The ID of the plan you want to check the user's subscription status against (ask Contentpass team for details)
- `issuer` - The OAuth 2.0 server URL (e.g. `https://my.contentpass.net`)
- `redirectUrl` - the redirect URL of your app to which the OAuth2 server will redirect after the authentication
- `samplingRate` - Optional: The rate at which the SDK will send impression events for unauthenticated users. Default is 0.05 (5%)
- `logLevel` - Optional: The log level for the SDK. By default logger is disabled. Possible values are 'info', 'warn', 'error' and 'debug'


```jsx
Expand All @@ -52,6 +54,8 @@ const contentpassConfig = {
planId: 'plan-id',
issuer: 'https://my.contentpass.net',
redirectUrl: 'com.yourapp://oauthredirect',
samplingRate: 0.1,
logLevel: 'info'
};

const App = () => {
Expand All @@ -72,6 +76,10 @@ The SDK exposes the following methods through the `useContentpassSdk` hook:
Initiates the OAuth 2.0 authentication process via a modal interface. It validates the user’s active Contentpass subscriptions
upon successful authentication.

### countImpression
Tracks and increments the impression count for the current user. This method should be invoked whenever a user views a
piece of content. It applies to all users, whether authenticated or unauthenticated.

### registerObserver
Registers a callback function to listen for changes in the user’s authentication and subscription status. The observer function
receives a state object describing the current status (see the exported [ContentpassState](./src/types/ContentpassState.ts) type).
Expand All @@ -96,6 +104,7 @@ import { Button, View } from 'react-native';
const YourApp = () => {
const {
authenticate,
countImpression,
registerObserver,
unregisterObserver,
logout,
Expand All @@ -117,6 +126,7 @@ const YourApp = () => {
return (
<View>
<Button onPress={authenticate} title={'Authenticate'} />
<Button onPress={countImpression} title={'Count Impression'} />
</View>
);
};
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@
"version": "0.44.1"
},
"dependencies": {
"@sentry/react-native": "^6.3.0"
"@sentry/react-native": "^6.3.0",
"react-native-logs": "^5.3.0",
"react-native-uuid": "^2.0.3"
}
}
5 changes: 5 additions & 0 deletions sharedExample/src/ContentpassUsage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,18 @@ export default function ContentpassUsage() {
spConsentManager.current?.loadMessage();
};

const countImpression = async () => {
await contentpassSdk.countImpression();
};

return (
<>
<View style={styles.buttonsContainer}>
<Button
title={'Clear sourcepoint data'}
onPress={clearSourcepointData}
/>
<Button title={'Count impression'} onPress={countImpression} />
<Button title={'Logout'} onPress={contentpassSdk.logout} />
</View>
<View style={styles.logsView}>
Expand Down
4 changes: 4 additions & 0 deletions sharedExample/src/contentpassConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ export const contentpassConfig: ContentpassConfig = {
propertyId: 'cc3fc4ad-cbe5-4d09-bf85-a49796603b19',
planId: 'a4721db5-67df-4145-bbbf-cbd09f7e0397',
issuer: 'https://my.contentpass.dev',
apiUrl: 'https://cp.cmp-sourcepoint.contenttimes.dev',
// Staging app
// propertyId: '78da2fd3-8b25-4642-b7b7-4a0193d00f89',
// planId: '50abfd7f-8a5d-43c9-8a8c-0cb4b0cefe96',
// issuer: 'https://my.contentpass.io',
// apiUrl: 'cp.cmp-sourcepoint.contenttimes.io',

samplingRate: 1,
redirectUrl: 'de.contentpass.demo://oauth',
logLevel: 'debug',
};
144 changes: 127 additions & 17 deletions src/Contentpass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@ import * as AppAuthModule from 'react-native-app-auth';
import * as OidcAuthStateStorageModule from './OidcAuthStateStorage';
import type { ContentpassState } from './types/ContentpassState';
import OidcAuthStateStorage from './OidcAuthStateStorage';
import * as FetchContentpassTokenModule from './utils/fetchContentpassToken';
import * as FetchContentpassTokenModule from './contentpassTokenUtils/fetchContentpassToken';
import { SCOPES } from './consts/oidcConsts';
import * as SentryIntegrationModule from './sentryIntegration';
import * as SendStatsModule from './countImpressionUtils/sendStats';
import * as SendPageViewEventModule from './countImpressionUtils/sendPageViewEvent';

const config: ContentpassConfig = {
propertyId: 'propertyId-1',
propertyId: '5803179c-5b9f-40be-9a91-e67e8ea20593',
planId: 'planId-1',
redirectUrl: 'de.test.net://oauth',
issuer: 'https://issuer.net',
apiUrl: 'https://cp.property.com',
samplingRate: 1,
};

const NOW = new Date('2024-12-02T11:53:56.272Z').getTime();
Expand All @@ -37,13 +41,21 @@ const EXAMPLE_REFRESH_RESULT = {
accessTokenExpirationDate: '2024-12-03T10:00:50Z',
};

const CONTENTPASS_TOKEN_WITH_PLANS =
'eyJhbGciOiJSUzI1NiIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6WyIwYWNhZTkxNy1iZTk5LTQ4ZWEtYjhmMS0yMGZhNjhhNDdkM2EiLCI0NDIxNjI4Yy05NjA2LTRjMDEtOGU1ZC1jMmE5YmNhNjhhYjQiLCI3ZThkZTBjYy0zZTk3LTQ5YTItODgxZC05ZmZiNWI4NDE1MTUiLCJhNDcyMWRiNS02N2RmLTQxNDUtYmJiZi1jYmQwOWY3ZTAzOTciLCJjNGQzYjBmNS05ODlhLTRmN2ItOGFjNy0zZDhmZmE5NTcxN2YiLCI2NGRkOTkwNS05NmUxLTRmYjItOTgwZC01MDdmMTYzNzVmZTkiXSwiYXVkIjoiY2MzZmM0YWQiLCJpYXQiOjE3MzMxMzU2ODEsImV4cCI6MTczMzMxMjA4MX0.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ';

export const CONTENTPASS_TOKEN_WITHOUT_PLANS =
'eyJhbGciOiJSUzI1NiIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6W10sImF1ZCI6ImNjM2ZjNGFkIiwiaWF0IjoxNzMzMTM1NjgxLCJleHAiOjE3MzMzMTIwODF9.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ';

describe('Contentpass', () => {
let contentpass: Contentpass;
let authorizeSpy: jest.SpyInstance;
let refreshSpy: jest.SpyInstance;
let reportErrorSpy: jest.SpyInstance;
let fetchContentpassTokenSpy: jest.SpyInstance;
let oidcAuthStorageMock: OidcAuthStateStorage;
let sendStatsSpy: jest.SpyInstance;
let sendPageViewEventSpy: jest.SpyInstance;

beforeEach(() => {
jest.useFakeTimers({ now: NOW });
Expand All @@ -69,9 +81,15 @@ describe('Contentpass', () => {

fetchContentpassTokenSpy = jest
.spyOn(FetchContentpassTokenModule, 'default')
.mockResolvedValue(
'eyJhbGciOiJSUzI1NiIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6WyIwYWNhZTkxNy1iZTk5LTQ4ZWEtYjhmMS0yMGZhNjhhNDdkM2EiLCI0NDIxNjI4Yy05NjA2LTRjMDEtOGU1ZC1jMmE5YmNhNjhhYjQiLCI3ZThkZTBjYy0zZTk3LTQ5YTItODgxZC05ZmZiNWI4NDE1MTUiLCJhNDcyMWRiNS02N2RmLTQxNDUtYmJiZi1jYmQwOWY3ZTAzOTciLCJjNGQzYjBmNS05ODlhLTRmN2ItOGFjNy0zZDhmZmE5NTcxN2YiLCI2NGRkOTkwNS05NmUxLTRmYjItOTgwZC01MDdmMTYzNzVmZTkiXSwiYXVkIjoiY2MzZmM0YWQiLCJpYXQiOjE3MzMxMzU2ODEsImV4cCI6MTczMzMxMjA4MX0.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ'
);
.mockResolvedValue(CONTENTPASS_TOKEN_WITH_PLANS);

sendStatsSpy = jest
.spyOn(SendStatsModule, 'default')
.mockResolvedValue({ ok: true } as any);

sendPageViewEventSpy = jest
.spyOn(SendPageViewEventModule, 'default')
.mockResolvedValue({ ok: true } as any);

contentpass = new Contentpass(config);
});
Expand All @@ -83,6 +101,24 @@ describe('Contentpass', () => {
});

describe('constructor', () => {
it('should throw an error if sampling rate is not between 0 and 1', () => {
expect(
() =>
new Contentpass({
...config,
samplingRate: -1,
})
).toThrow('Sampling rate must be between 0 and 1');

expect(
() =>
new Contentpass({
...config,
samplingRate: 2,
})
).toThrow('Sampling rate must be between 0 and 1');
});

it('should initialise contentpass state', () => {
const contentpassStates: ContentpassState[] = [];
contentpass.registerObserver((state) => {
Expand Down Expand Up @@ -138,9 +174,9 @@ describe('Contentpass', () => {
expect(refreshSpy).toHaveBeenCalledTimes(1);
expect(refreshSpy).toHaveBeenCalledWith(
{
clientId: 'propertyId-1',
redirectUrl: 'de.test.net://oauth',
issuer: 'https://issuer.net',
clientId: config.propertyId,
redirectUrl: config.redirectUrl,
issuer: config.issuer,
scopes: SCOPES,
},
{ refreshToken: EXAMPLE_AUTH_RESULT.refreshToken }
Expand All @@ -159,14 +195,14 @@ describe('Contentpass', () => {

expect(authorizeSpy).toHaveBeenCalledWith({
additionalParameters: {
cp_property: 'propertyId-1',
cp_plan: 'planId-1',
cp_property: config.propertyId,
cp_plan: config.planId,
cp_route: 'login',
prompt: 'consent',
},
clientId: 'propertyId-1',
issuer: 'https://issuer.net',
redirectUrl: 'de.test.net://oauth',
clientId: config.propertyId,
issuer: config.issuer,
redirectUrl: config.redirectUrl,
scopes: ['openid', 'offline_access', 'contentpass'],
});
});
Expand Down Expand Up @@ -250,9 +286,9 @@ describe('Contentpass', () => {
expect(refreshSpy).toHaveBeenCalledTimes(1);
expect(refreshSpy).toHaveBeenCalledWith(
{
clientId: 'propertyId-1',
redirectUrl: 'de.test.net://oauth',
issuer: 'https://issuer.net',
clientId: config.propertyId,
redirectUrl: config.redirectUrl,
issuer: config.issuer,
scopes: SCOPES,
},
{ refreshToken: EXAMPLE_AUTH_RESULT.refreshToken }
Expand Down Expand Up @@ -325,7 +361,7 @@ describe('Contentpass', () => {

// after 6 retries the state should change to error
await jest.advanceTimersByTimeAsync(120001);
expect(reportErrorSpy).toHaveBeenCalledTimes(7);
expect(reportErrorSpy).toHaveBeenCalledTimes(1);
expect(reportErrorSpy).toHaveBeenCalledWith(refreshError, {
msg: 'Failed to refresh token after 6 retries',
});
Expand Down Expand Up @@ -438,4 +474,78 @@ describe('Contentpass', () => {
});
});
});

describe('countImpression', () => {
it('should count paid impression if user has valid subscription and access token', async () => {
await contentpass.authenticate();

await contentpass.countImpression();

expect(sendPageViewEventSpy).toHaveBeenCalledWith(config.apiUrl, {
propertyId: config.propertyId,
impressionId: expect.any(String),
accessToken: EXAMPLE_AUTH_RESULT.accessToken,
});
expect(sendStatsSpy).toHaveBeenCalled();
});

it('should not count paid impression if user is authenticated, but does not have valid subscription', async () => {
fetchContentpassTokenSpy.mockResolvedValue(
CONTENTPASS_TOKEN_WITHOUT_PLANS
);
await contentpass.authenticate();

await contentpass.countImpression();

expect(sendPageViewEventSpy).not.toHaveBeenCalled();
expect(sendStatsSpy).toHaveBeenCalled();
});

it('should report error if counting paid impression fails', async () => {
await contentpass.authenticate();

const error = new Error('Send page view event error');
sendPageViewEventSpy.mockRejectedValue(error);

await contentpass.countImpression();

expect(reportErrorSpy).toHaveBeenCalledWith(error, {
msg: 'Failed to count paid impression',
});
expect(sendStatsSpy).toHaveBeenCalled();
});

it('should count sampled impression even if user does not have valid subscription', async () => {
await contentpass.countImpression();

expect(sendPageViewEventSpy).not.toHaveBeenCalled();
expect(sendStatsSpy).toHaveBeenCalledWith(config.apiUrl, {
cpabid: expect.any(String),
cppid: '5803179c',
cpsr: config.samplingRate,
ea: 'load',
ec: 'tcf-sampled',
});
});

it('should report error if counting sampled impression fails', async () => {
const error = new Error('Send stats error');
sendStatsSpy.mockRejectedValue(error);

await contentpass.countImpression();

expect(reportErrorSpy).toHaveBeenCalledWith(error, {
msg: 'Failed to count sampled impression',
});
});

it('should not send sampled impression when generatedSample is higher than samplingRate', async () => {
jest.spyOn(Math, 'random').mockReturnValue(0.5);
contentpass = new Contentpass({ ...config, samplingRate: 0.4 });

await contentpass.countImpression();
expect(sendStatsSpy).not.toHaveBeenCalled();
expect(sendPageViewEventSpy).not.toHaveBeenCalled();
});
});
});
Loading
Loading