Skip to content

Commit 00a50f3

Browse files
committed
feat: implement countImpression method
1 parent 5d208d4 commit 00a50f3

19 files changed

+389
-19
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ The SDK exposes the following methods through the `useContentpassSdk` hook:
7070
Initiates the OAuth 2.0 authentication process via a modal interface. It validates the user’s active Contentpass subscriptions
7171
upon successful authentication.
7272

73+
### countImpression
74+
Increments the impression count for the current user. This method should be called whenever the user views a piece of content.
75+
7376
### registerObserver
7477
Registers a callback function to listen for changes in the user’s authentication and subscription status. The observer function
7578
receives a state object describing the current status (see the exported [ContentpassState](./src/types/ContentpassState.ts) type).
@@ -94,6 +97,7 @@ import { Button, View } from 'react-native';
9497
const YourApp = () => {
9598
const {
9699
authenticate,
100+
countImpression,
97101
registerObserver,
98102
unregisterObserver,
99103
logout,
@@ -115,6 +119,7 @@ const YourApp = () => {
115119
return (
116120
<View>
117121
<Button onPress={authenticate} title={'Authenticate'} />
122+
<Button onPress={countImpression} title={'Count Impression'} />
118123
</View>
119124
);
120125
};

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@
149149
"version": "0.44.1"
150150
},
151151
"dependencies": {
152-
"@sentry/react-native": "^6.3.0"
152+
"@sentry/react-native": "^6.3.0",
153+
"react-native-uuid": "^2.0.3"
153154
}
154155
}

sharedExample/src/ContentpassUsage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,18 @@ export default function ContentpassUsage() {
9393
spConsentManager.current?.loadMessage();
9494
};
9595

96+
const countImpression = async () => {
97+
await contentpassSdk.countImpression();
98+
};
99+
96100
return (
97101
<>
98102
<View style={styles.buttonsContainer}>
99103
<Button
100104
title={'Clear sourcepoint data'}
101105
onPress={clearSourcepointData}
102106
/>
107+
<Button title={'Count impression'} onPress={countImpression} />
103108
<Button title={'Logout'} onPress={contentpassSdk.logout} />
104109
</View>
105110
<View style={styles.logsView}>

sharedExample/src/contentpassConfig.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ export const contentpassConfig: ContentpassConfig = {
44
// Testing app
55
propertyId: 'cc3fc4ad-cbe5-4d09-bf85-a49796603b19',
66
issuer: 'https://my.contentpass.dev',
7+
apiUrl: 'https://cp.cmp-sourcepoint.contenttimes.dev',
78
// Staging app
89
// propertyId: '78da2fd3-8b25-4642-b7b7-4a0193d00f89',
910
// issuer: 'https://my.contentpass.io',
11+
// apiUrl: 'cp.cmp-sourcepoint.contenttimes.io',
1012

13+
samplingRate: 1,
1114
redirectUrl: 'de.contentpass.demo://oauth',
1215
};

src/Contentpass.test.ts

Lines changed: 125 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ import * as AppAuthModule from 'react-native-app-auth';
55
import * as OidcAuthStateStorageModule from './OidcAuthStateStorage';
66
import type { ContentpassState } from './types/ContentpassState';
77
import OidcAuthStateStorage from './OidcAuthStateStorage';
8-
import * as FetchContentpassTokenModule from './utils/fetchContentpassToken';
8+
import * as FetchContentpassTokenModule from './contentpassTokenUtils/fetchContentpassToken';
99
import { SCOPES } from './consts/oidcConsts';
1010
import * as SentryIntegrationModule from './sentryIntegration';
11+
import * as SendStatsModule from './countImpressionUtils/sendStats';
12+
import * as SendPageViewEventModule from './countImpressionUtils/sendPageViewEvent';
1113

1214
const config: ContentpassConfig = {
13-
propertyId: 'propertyId-1',
15+
propertyId: '5803179c-5b9f-40be-9a91-e67e8ea20593',
1416
redirectUrl: 'de.test.net://oauth',
1517
issuer: 'https://issuer.net',
18+
apiUrl: 'https://cp.property.com',
19+
samplingRate: 1,
1620
};
1721

1822
const NOW = new Date('2024-12-02T11:53:56.272Z').getTime();
@@ -36,13 +40,21 @@ const EXAMPLE_REFRESH_RESULT = {
3640
accessTokenExpirationDate: '2024-12-03T10:00:50Z',
3741
};
3842

43+
const CONTENTPASS_TOKEN_WITH_PLANS =
44+
'eyJhbGciOiJSUzI1NiIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6WyIwYWNhZTkxNy1iZTk5LTQ4ZWEtYjhmMS0yMGZhNjhhNDdkM2EiLCI0NDIxNjI4Yy05NjA2LTRjMDEtOGU1ZC1jMmE5YmNhNjhhYjQiLCI3ZThkZTBjYy0zZTk3LTQ5YTItODgxZC05ZmZiNWI4NDE1MTUiLCJhNDcyMWRiNS02N2RmLTQxNDUtYmJiZi1jYmQwOWY3ZTAzOTciLCJjNGQzYjBmNS05ODlhLTRmN2ItOGFjNy0zZDhmZmE5NTcxN2YiLCI2NGRkOTkwNS05NmUxLTRmYjItOTgwZC01MDdmMTYzNzVmZTkiXSwiYXVkIjoiY2MzZmM0YWQiLCJpYXQiOjE3MzMxMzU2ODEsImV4cCI6MTczMzMxMjA4MX0.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ';
45+
46+
export const CONTENTPASS_TOKEN_WITHOUT_PLANS =
47+
'eyJhbGciOiJSUzI1NiIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6W10sImF1ZCI6ImNjM2ZjNGFkIiwiaWF0IjoxNzMzMTM1NjgxLCJleHAiOjE3MzMzMTIwODF9.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ';
48+
3949
describe('Contentpass', () => {
4050
let contentpass: Contentpass;
4151
let authorizeSpy: jest.SpyInstance;
4252
let refreshSpy: jest.SpyInstance;
4353
let reportErrorSpy: jest.SpyInstance;
4454
let fetchContentpassTokenSpy: jest.SpyInstance;
4555
let oidcAuthStorageMock: OidcAuthStateStorage;
56+
let sendStatsSpy: jest.SpyInstance;
57+
let sendPageViewEventSpy: jest.SpyInstance;
4658

4759
beforeEach(() => {
4860
jest.useFakeTimers({ now: NOW });
@@ -68,9 +80,15 @@ describe('Contentpass', () => {
6880

6981
fetchContentpassTokenSpy = jest
7082
.spyOn(FetchContentpassTokenModule, 'default')
71-
.mockResolvedValue(
72-
'eyJhbGciOiJSUzI1NiIsImtpZCI6IjY5NzUwYTZjLTNmYjctNDUyNi05NWY4LTVhZmYxMmIyZjFjOSJ9.eyJhdXRoIjp0cnVlLCJ0eXBlIjoiY3AiLCJwbGFucyI6WyIwYWNhZTkxNy1iZTk5LTQ4ZWEtYjhmMS0yMGZhNjhhNDdkM2EiLCI0NDIxNjI4Yy05NjA2LTRjMDEtOGU1ZC1jMmE5YmNhNjhhYjQiLCI3ZThkZTBjYy0zZTk3LTQ5YTItODgxZC05ZmZiNWI4NDE1MTUiLCJhNDcyMWRiNS02N2RmLTQxNDUtYmJiZi1jYmQwOWY3ZTAzOTciLCJjNGQzYjBmNS05ODlhLTRmN2ItOGFjNy0zZDhmZmE5NTcxN2YiLCI2NGRkOTkwNS05NmUxLTRmYjItOTgwZC01MDdmMTYzNzVmZTkiXSwiYXVkIjoiY2MzZmM0YWQiLCJpYXQiOjE3MzMxMzU2ODEsImV4cCI6MTczMzMxMjA4MX0.CMtH7HRLf2HVgw3_cZRN0en8tml_SQKM73iLGJAp72-vJuRJaq85xBp6Jgy9WD3L7x4itRlBAYZxX8tLxZGogU0WP4_dMGFQ2QlcwKshwJygwRM1YqvxGWX2Az_KxEMc2QGHvpE1qe2MAr_xOU7VFfc0-vWxFc3hRzpAM5j7YHctj2t1v6h9-M7V2Hkcn37569QmtgU8gJkUxXsgUTufbb1ikjjjAvnjvTluHJo51_utbimpUbCk3EFxXVCVEI_pAqiZQXNninUQ6dbSujLb3L2UlEdQzLeUiBdYroeFzSyruLrR841ledLQ5ZP2OqzF5oUMuAGVOOhmgGdwGMCDRQ'
73-
);
83+
.mockResolvedValue(CONTENTPASS_TOKEN_WITH_PLANS);
84+
85+
sendStatsSpy = jest
86+
.spyOn(SendStatsModule, 'default')
87+
.mockResolvedValue({ ok: true } as any);
88+
89+
sendPageViewEventSpy = jest
90+
.spyOn(SendPageViewEventModule, 'default')
91+
.mockResolvedValue({ ok: true } as any);
7492

7593
contentpass = new Contentpass(config);
7694
});
@@ -82,6 +100,24 @@ describe('Contentpass', () => {
82100
});
83101

84102
describe('constructor', () => {
103+
it('should throw an error if sampling rate is not between 0 and 1', () => {
104+
expect(
105+
() =>
106+
new Contentpass({
107+
...config,
108+
samplingRate: -1,
109+
})
110+
).toThrow('Sampling rate must be between 0 and 1');
111+
112+
expect(
113+
() =>
114+
new Contentpass({
115+
...config,
116+
samplingRate: 2,
117+
})
118+
).toThrow('Sampling rate must be between 0 and 1');
119+
});
120+
85121
it('should initialise contentpass state', () => {
86122
const contentpassStates: ContentpassState[] = [];
87123
contentpass.registerObserver((state) => {
@@ -137,9 +173,9 @@ describe('Contentpass', () => {
137173
expect(refreshSpy).toHaveBeenCalledTimes(1);
138174
expect(refreshSpy).toHaveBeenCalledWith(
139175
{
140-
clientId: 'propertyId-1',
141-
redirectUrl: 'de.test.net://oauth',
142-
issuer: 'https://issuer.net',
176+
clientId: config.propertyId,
177+
redirectUrl: config.redirectUrl,
178+
issuer: config.issuer,
143179
scopes: SCOPES,
144180
},
145181
{ refreshToken: EXAMPLE_AUTH_RESULT.refreshToken }
@@ -158,13 +194,13 @@ describe('Contentpass', () => {
158194

159195
expect(authorizeSpy).toHaveBeenCalledWith({
160196
additionalParameters: {
161-
cp_property: 'propertyId-1',
197+
cp_property: config.propertyId,
162198
cp_route: 'login',
163199
prompt: 'consent',
164200
},
165-
clientId: 'propertyId-1',
166-
issuer: 'https://issuer.net',
167-
redirectUrl: 'de.test.net://oauth',
201+
clientId: config.propertyId,
202+
issuer: config.issuer,
203+
redirectUrl: config.redirectUrl,
168204
scopes: ['openid', 'offline_access', 'contentpass'],
169205
});
170206
});
@@ -248,9 +284,9 @@ describe('Contentpass', () => {
248284
expect(refreshSpy).toHaveBeenCalledTimes(1);
249285
expect(refreshSpy).toHaveBeenCalledWith(
250286
{
251-
clientId: 'propertyId-1',
252-
redirectUrl: 'de.test.net://oauth',
253-
issuer: 'https://issuer.net',
287+
clientId: config.propertyId,
288+
redirectUrl: config.redirectUrl,
289+
issuer: config.issuer,
254290
scopes: SCOPES,
255291
},
256292
{ refreshToken: EXAMPLE_AUTH_RESULT.refreshToken }
@@ -436,4 +472,78 @@ describe('Contentpass', () => {
436472
});
437473
});
438474
});
475+
476+
describe('countImpression', () => {
477+
it('should count paid impression if user has valid subscription and access token', async () => {
478+
await contentpass.authenticate();
479+
480+
await contentpass.countImpression();
481+
482+
expect(sendPageViewEventSpy).toHaveBeenCalledWith(config.apiUrl, {
483+
propertyId: config.propertyId,
484+
impressionId: expect.any(String),
485+
accessToken: EXAMPLE_AUTH_RESULT.accessToken,
486+
});
487+
expect(sendStatsSpy).toHaveBeenCalled();
488+
});
489+
490+
it('should not count paid impression if user is authenticated, but does not have valid subscription', async () => {
491+
fetchContentpassTokenSpy.mockResolvedValue(
492+
CONTENTPASS_TOKEN_WITHOUT_PLANS
493+
);
494+
await contentpass.authenticate();
495+
496+
await contentpass.countImpression();
497+
498+
expect(sendPageViewEventSpy).not.toHaveBeenCalled();
499+
expect(sendStatsSpy).toHaveBeenCalled();
500+
});
501+
502+
it('should report error if counting paid impression fails', async () => {
503+
await contentpass.authenticate();
504+
505+
const error = new Error('Send page view event error');
506+
sendPageViewEventSpy.mockRejectedValue(error);
507+
508+
await contentpass.countImpression();
509+
510+
expect(reportErrorSpy).toHaveBeenCalledWith(error, {
511+
msg: 'Failed to count paid impression',
512+
});
513+
expect(sendStatsSpy).toHaveBeenCalled();
514+
});
515+
516+
it('should count sampled impression even if user does not have valid subscription', async () => {
517+
await contentpass.countImpression();
518+
519+
expect(sendPageViewEventSpy).not.toHaveBeenCalled();
520+
expect(sendStatsSpy).toHaveBeenCalledWith(config.apiUrl, {
521+
cpabid: expect.any(String),
522+
cppid: '5803179c',
523+
cpsr: config.samplingRate,
524+
ea: 'load',
525+
ec: 'tcf-sampled',
526+
});
527+
});
528+
529+
it('should report error if counting sampled impression fails', async () => {
530+
const error = new Error('Send stats error');
531+
sendStatsSpy.mockRejectedValue(error);
532+
533+
await contentpass.countImpression();
534+
535+
expect(reportErrorSpy).toHaveBeenCalledWith(error, {
536+
msg: 'Failed to count sampled impression',
537+
});
538+
});
539+
540+
it('should not send sampled impression when generatedSample is higher than samplingRate', async () => {
541+
jest.spyOn(Math, 'random').mockReturnValue(0.5);
542+
contentpass = new Contentpass({ ...config, samplingRate: 0.4 });
543+
544+
await contentpass.countImpression();
545+
expect(sendStatsSpy).not.toHaveBeenCalled();
546+
expect(sendPageViewEventSpy).not.toHaveBeenCalled();
547+
});
548+
});
439549
});

src/Contentpass.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid from 'react-native-uuid';
12
import OidcAuthStateStorage, {
23
type OidcAuthState,
34
} from './OidcAuthStateStorage';
@@ -12,16 +13,31 @@ import {
1213
} from 'react-native-app-auth';
1314
import { REFRESH_TOKEN_RETRIES, SCOPES } from './consts/oidcConsts';
1415
import { RefreshTokenStrategy } from './types/RefreshTokenStrategy';
15-
import fetchContentpassToken from './utils/fetchContentpassToken';
16-
import validateSubscription from './utils/validateSubscription';
16+
import fetchContentpassToken from './contentpassTokenUtils/fetchContentpassToken';
17+
import validateSubscription from './contentpassTokenUtils/validateSubscription';
1718
import type { ContentpassConfig } from './types/ContentpassConfig';
1819
import { reportError, setSentryExtraAttribute } from './sentryIntegration';
20+
import sendStats from './countImpressionUtils/sendStats';
21+
import sendPageViewEvent from './countImpressionUtils/sendPageViewEvent';
22+
23+
const DEFAULT_FREE_IMPRESSIONS_SAMPLING_RATE = 0.01;
1924

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

22-
export default class Contentpass {
27+
interface ContentpassInterface {
28+
authenticate: () => Promise<void>;
29+
registerObserver: (observer: ContentpassObserver) => void;
30+
unregisterObserver: (observer: ContentpassObserver) => void;
31+
logout: () => Promise<void>;
32+
recoverFromError: () => Promise<void>;
33+
countImpression: () => Promise<void>;
34+
}
35+
36+
export default class Contentpass implements ContentpassInterface {
2337
private authStateStorage: OidcAuthStateStorage;
2438
private readonly config: ContentpassConfig;
39+
private readonly samplingRate: number;
40+
private readonly instanceId: string;
2541

2642
private contentpassState: ContentpassState = {
2743
state: ContentpassStateType.INITIALISING,
@@ -31,6 +47,15 @@ export default class Contentpass {
3147
private refreshTimer: NodeJS.Timeout | null = null;
3248

3349
constructor(config: ContentpassConfig) {
50+
if (
51+
config.samplingRate &&
52+
(config.samplingRate < 0 || config.samplingRate > 1)
53+
) {
54+
throw new Error('Sampling rate must be between 0 and 1');
55+
}
56+
this.samplingRate =
57+
config.samplingRate || DEFAULT_FREE_IMPRESSIONS_SAMPLING_RATE;
58+
this.instanceId = uuid.v4();
3459
this.authStateStorage = new OidcAuthStateStorage(config.propertyId);
3560
this.config = config;
3661
setSentryExtraAttribute('propertyId', config.propertyId);
@@ -96,6 +121,49 @@ export default class Contentpass {
96121
await this.initialiseAuthState();
97122
};
98123

124+
public countImpression = async () => {
125+
if (this.hasValidSubscriptionAndAccessToken()) {
126+
try {
127+
await this.countPaidImpression();
128+
} catch (err: any) {
129+
reportError(err, { msg: 'Failed to count paid impression' });
130+
}
131+
}
132+
133+
try {
134+
await this.countSampledImpression();
135+
} catch (err: any) {
136+
reportError(err, { msg: 'Failed to count sampled impression' });
137+
}
138+
};
139+
140+
private countPaidImpression = async () => {
141+
const impressionId = uuid.v4();
142+
143+
await sendPageViewEvent(this.config.apiUrl, {
144+
propertyId: this.config.propertyId,
145+
impressionId,
146+
accessToken: this.oidcAuthState!.accessToken,
147+
});
148+
};
149+
150+
private countSampledImpression = async () => {
151+
const generatedSample = Math.random();
152+
const publicId = this.config.propertyId.slice(0, 8);
153+
154+
if (generatedSample >= this.samplingRate) {
155+
return;
156+
}
157+
158+
await sendStats(this.config.apiUrl, {
159+
ea: 'load',
160+
ec: 'tcf-sampled',
161+
cpabid: this.instanceId,
162+
cppid: publicId,
163+
cpsr: this.samplingRate,
164+
});
165+
};
166+
99167
private initialiseAuthState = async () => {
100168
const authState = await this.authStateStorage.getOidcAuthState();
101169
if (authState) {
@@ -208,4 +276,12 @@ export default class Contentpass {
208276
this.contentpassState = state;
209277
this.contentpassStateObservers.forEach((observer) => observer(state));
210278
};
279+
280+
private hasValidSubscriptionAndAccessToken = () => {
281+
return (
282+
this.contentpassState.state === ContentpassStateType.AUTHENTICATED &&
283+
this.contentpassState.hasValidSubscription &&
284+
this.oidcAuthState?.accessToken
285+
);
286+
};
211287
}

0 commit comments

Comments
 (0)