Skip to content
Closed
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,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 @@ -94,6 +98,7 @@ import { Button, View } from 'react-native';
const YourApp = () => {
const {
authenticate,
countImpression,
registerObserver,
unregisterObserver,
logout,
Expand All @@ -115,6 +120,7 @@ const YourApp = () => {
return (
<View>
<Button onPress={authenticate} title={'Authenticate'} />
<Button onPress={countImpression} title={'Count Impression'} />
</View>
);
};
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
"version": "0.44.1"
},
"dependencies": {
"@sentry/react-native": "^6.3.0"
"@sentry/react-native": "^6.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
3 changes: 3 additions & 0 deletions sharedExample/src/contentpassConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ export const contentpassConfig: ContentpassConfig = {
// Testing app
propertyId: 'cc3fc4ad-cbe5-4d09-bf85-a49796603b19',
issuer: 'https://my.contentpass.dev',
apiUrl: 'https://cp.cmp-sourcepoint.contenttimes.dev',
// Staging app
// propertyId: '78da2fd3-8b25-4642-b7b7-4a0193d00f89',
// issuer: 'https://my.contentpass.io',
// apiUrl: 'cp.cmp-sourcepoint.contenttimes.io',

samplingRate: 1,
redirectUrl: 'de.contentpass.demo://oauth',
};
140 changes: 125 additions & 15 deletions src/Contentpass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ 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',
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 @@ -36,13 +40,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 @@ -68,9 +80,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 @@ -82,6 +100,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 @@ -137,9 +173,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 @@ -158,13 +194,13 @@ describe('Contentpass', () => {

expect(authorizeSpy).toHaveBeenCalledWith({
additionalParameters: {
cp_property: 'propertyId-1',
cp_property: config.propertyId,
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 @@ -248,9 +284,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 @@ -436,4 +472,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();
});
});
});
81 changes: 78 additions & 3 deletions src/Contentpass.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import uuid from 'react-native-uuid';
import OidcAuthStateStorage, {
type OidcAuthState,
} from './OidcAuthStateStorage';
Expand All @@ -12,16 +13,31 @@ import {
} from 'react-native-app-auth';
import { REFRESH_TOKEN_RETRIES, SCOPES } from './consts/oidcConsts';
import { RefreshTokenStrategy } from './types/RefreshTokenStrategy';
import fetchContentpassToken from './utils/fetchContentpassToken';
import validateSubscription from './utils/validateSubscription';
import fetchContentpassToken from './contentpassTokenUtils/fetchContentpassToken';
import validateSubscription from './contentpassTokenUtils/validateSubscription';
import type { ContentpassConfig } from './types/ContentpassConfig';
import { reportError, setSentryExtraAttribute } from './sentryIntegration';
import sendStats from './countImpressionUtils/sendStats';
import sendPageViewEvent from './countImpressionUtils/sendPageViewEvent';

const DEFAULT_SAMPLING_RATE = 0.05;

export type ContentpassObserver = (state: ContentpassState) => void;

export default class Contentpass {
interface ContentpassInterface {
authenticate: () => Promise<void>;
registerObserver: (observer: ContentpassObserver) => void;
unregisterObserver: (observer: ContentpassObserver) => void;
logout: () => Promise<void>;
recoverFromError: () => Promise<void>;
countImpression: () => Promise<void>;
}

export default class Contentpass implements ContentpassInterface {
private authStateStorage: OidcAuthStateStorage;
private readonly config: ContentpassConfig;
private readonly samplingRate: number;
private readonly instanceId: string;

private contentpassState: ContentpassState = {
state: ContentpassStateType.INITIALISING,
Expand All @@ -31,6 +47,14 @@ export default class Contentpass {
private refreshTimer: NodeJS.Timeout | null = null;

constructor(config: ContentpassConfig) {
if (
config.samplingRate &&
(config.samplingRate < 0 || config.samplingRate > 1)
) {
throw new Error('Sampling rate must be between 0 and 1');
}
this.samplingRate = config.samplingRate || DEFAULT_SAMPLING_RATE;
this.instanceId = uuid.v4();
this.authStateStorage = new OidcAuthStateStorage(config.propertyId);
this.config = config;
setSentryExtraAttribute('propertyId', config.propertyId);
Expand Down Expand Up @@ -96,6 +120,49 @@ export default class Contentpass {
await this.initialiseAuthState();
};

public countImpression = async () => {
if (this.hasValidSubscriptionAndAccessToken()) {
try {
await this.countPaidImpression();
} catch (err: any) {
reportError(err, { msg: 'Failed to count paid impression' });
}
}

try {
await this.countSampledImpression();
} catch (err: any) {
reportError(err, { msg: 'Failed to count sampled impression' });
}
};

private countPaidImpression = async () => {
const impressionId = uuid.v4();

await sendPageViewEvent(this.config.apiUrl, {
propertyId: this.config.propertyId,
impressionId,
accessToken: this.oidcAuthState!.accessToken,
});
};

private countSampledImpression = async () => {
const generatedSample = Math.random();
const publicId = this.config.propertyId.slice(0, 8);

if (generatedSample >= this.samplingRate) {
return;
}

await sendStats(this.config.apiUrl, {
ea: 'load',
ec: 'tcf-sampled',
cpabid: this.instanceId,
cppid: publicId,
cpsr: this.samplingRate,
});
};

private initialiseAuthState = async () => {
const authState = await this.authStateStorage.getOidcAuthState();
if (authState) {
Expand Down Expand Up @@ -208,4 +275,12 @@ export default class Contentpass {
this.contentpassState = state;
this.contentpassStateObservers.forEach((observer) => observer(state));
};

private hasValidSubscriptionAndAccessToken = () => {
return (
this.contentpassState.state === ContentpassStateType.AUTHENTICATED &&
this.contentpassState.hasValidSubscription &&
this.oidcAuthState?.accessToken
);
};
}
Loading