Skip to content

Commit ad77563

Browse files
committed
feat: implement countImpression method
1 parent 1061ff2 commit ad77563

19 files changed

+390
-20
lines changed

README.md

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

75+
### countImpression
76+
Tracks and increments the impression count for the current user. This method should be invoked whenever a user views a
77+
piece of content. It applies to all users, whether authenticated or unauthenticated.
78+
7579
### registerObserver
7680
Registers a callback function to listen for changes in the user’s authentication and subscription status. The observer function
7781
receives a state object describing the current status (see the exported [ContentpassState](./src/types/ContentpassState.ts) type).
@@ -96,6 +100,7 @@ import { Button, View } from 'react-native';
96100
const YourApp = () => {
97101
const {
98102
authenticate,
103+
countImpression,
99104
registerObserver,
100105
unregisterObserver,
101106
logout,
@@ -117,6 +122,7 @@ const YourApp = () => {
117122
return (
118123
<View>
119124
<Button onPress={authenticate} title={'Authenticate'} />
125+
<Button onPress={countImpression} title={'Count Impression'} />
120126
</View>
121127
);
122128
};

package.json

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

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
@@ -5,10 +5,13 @@ export const contentpassConfig: ContentpassConfig = {
55
propertyId: 'cc3fc4ad-cbe5-4d09-bf85-a49796603b19',
66
planId: 'a4721db5-67df-4145-bbbf-cbd09f7e0397',
77
issuer: 'https://my.contentpass.dev',
8+
apiUrl: 'https://cp.cmp-sourcepoint.contenttimes.dev',
89
// Staging app
910
// propertyId: '78da2fd3-8b25-4642-b7b7-4a0193d00f89',
1011
// planId: '50abfd7f-8a5d-43c9-8a8c-0cb4b0cefe96',
1112
// issuer: 'https://my.contentpass.io',
13+
// apiUrl: 'cp.cmp-sourcepoint.contenttimes.io',
1214

15+
samplingRate: 1,
1316
redirectUrl: 'de.contentpass.demo://oauth',
1417
};

src/Contentpass.test.ts

Lines changed: 126 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@ 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
planId: 'planId-1',
1517
redirectUrl: 'de.test.net://oauth',
1618
issuer: 'https://issuer.net',
19+
apiUrl: 'https://cp.property.com',
20+
samplingRate: 1,
1721
};
1822

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

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

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

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

7694
contentpass = new Contentpass(config);
7795
});
@@ -83,6 +101,24 @@ describe('Contentpass', () => {
83101
});
84102

85103
describe('constructor', () => {
104+
it('should throw an error if sampling rate is not between 0 and 1', () => {
105+
expect(
106+
() =>
107+
new Contentpass({
108+
...config,
109+
samplingRate: -1,
110+
})
111+
).toThrow('Sampling rate must be between 0 and 1');
112+
113+
expect(
114+
() =>
115+
new Contentpass({
116+
...config,
117+
samplingRate: 2,
118+
})
119+
).toThrow('Sampling rate must be between 0 and 1');
120+
});
121+
86122
it('should initialise contentpass state', () => {
87123
const contentpassStates: ContentpassState[] = [];
88124
contentpass.registerObserver((state) => {
@@ -138,9 +174,9 @@ describe('Contentpass', () => {
138174
expect(refreshSpy).toHaveBeenCalledTimes(1);
139175
expect(refreshSpy).toHaveBeenCalledWith(
140176
{
141-
clientId: 'propertyId-1',
142-
redirectUrl: 'de.test.net://oauth',
143-
issuer: 'https://issuer.net',
177+
clientId: config.propertyId,
178+
redirectUrl: config.redirectUrl,
179+
issuer: config.issuer,
144180
scopes: SCOPES,
145181
},
146182
{ refreshToken: EXAMPLE_AUTH_RESULT.refreshToken }
@@ -159,14 +195,14 @@ describe('Contentpass', () => {
159195

160196
expect(authorizeSpy).toHaveBeenCalledWith({
161197
additionalParameters: {
162-
cp_property: 'propertyId-1',
163-
cp_plan: 'planId-1',
198+
cp_property: config.propertyId,
199+
cp_plan: config.planId,
164200
cp_route: 'login',
165201
prompt: 'consent',
166202
},
167-
clientId: 'propertyId-1',
168-
issuer: 'https://issuer.net',
169-
redirectUrl: 'de.test.net://oauth',
203+
clientId: config.propertyId,
204+
issuer: config.issuer,
205+
redirectUrl: config.redirectUrl,
170206
scopes: ['openid', 'offline_access', 'contentpass'],
171207
});
172208
});
@@ -250,9 +286,9 @@ describe('Contentpass', () => {
250286
expect(refreshSpy).toHaveBeenCalledTimes(1);
251287
expect(refreshSpy).toHaveBeenCalledWith(
252288
{
253-
clientId: 'propertyId-1',
254-
redirectUrl: 'de.test.net://oauth',
255-
issuer: 'https://issuer.net',
289+
clientId: config.propertyId,
290+
redirectUrl: config.redirectUrl,
291+
issuer: config.issuer,
256292
scopes: SCOPES,
257293
},
258294
{ refreshToken: EXAMPLE_AUTH_RESULT.refreshToken }
@@ -438,4 +474,78 @@ describe('Contentpass', () => {
438474
});
439475
});
440476
});
477+
478+
describe('countImpression', () => {
479+
it('should count paid impression if user has valid subscription and access token', async () => {
480+
await contentpass.authenticate();
481+
482+
await contentpass.countImpression();
483+
484+
expect(sendPageViewEventSpy).toHaveBeenCalledWith(config.apiUrl, {
485+
propertyId: config.propertyId,
486+
impressionId: expect.any(String),
487+
accessToken: EXAMPLE_AUTH_RESULT.accessToken,
488+
});
489+
expect(sendStatsSpy).toHaveBeenCalled();
490+
});
491+
492+
it('should not count paid impression if user is authenticated, but does not have valid subscription', async () => {
493+
fetchContentpassTokenSpy.mockResolvedValue(
494+
CONTENTPASS_TOKEN_WITHOUT_PLANS
495+
);
496+
await contentpass.authenticate();
497+
498+
await contentpass.countImpression();
499+
500+
expect(sendPageViewEventSpy).not.toHaveBeenCalled();
501+
expect(sendStatsSpy).toHaveBeenCalled();
502+
});
503+
504+
it('should report error if counting paid impression fails', async () => {
505+
await contentpass.authenticate();
506+
507+
const error = new Error('Send page view event error');
508+
sendPageViewEventSpy.mockRejectedValue(error);
509+
510+
await contentpass.countImpression();
511+
512+
expect(reportErrorSpy).toHaveBeenCalledWith(error, {
513+
msg: 'Failed to count paid impression',
514+
});
515+
expect(sendStatsSpy).toHaveBeenCalled();
516+
});
517+
518+
it('should count sampled impression even if user does not have valid subscription', async () => {
519+
await contentpass.countImpression();
520+
521+
expect(sendPageViewEventSpy).not.toHaveBeenCalled();
522+
expect(sendStatsSpy).toHaveBeenCalledWith(config.apiUrl, {
523+
cpabid: expect.any(String),
524+
cppid: '5803179c',
525+
cpsr: config.samplingRate,
526+
ea: 'load',
527+
ec: 'tcf-sampled',
528+
});
529+
});
530+
531+
it('should report error if counting sampled impression fails', async () => {
532+
const error = new Error('Send stats error');
533+
sendStatsSpy.mockRejectedValue(error);
534+
535+
await contentpass.countImpression();
536+
537+
expect(reportErrorSpy).toHaveBeenCalledWith(error, {
538+
msg: 'Failed to count sampled impression',
539+
});
540+
});
541+
542+
it('should not send sampled impression when generatedSample is higher than samplingRate', async () => {
543+
jest.spyOn(Math, 'random').mockReturnValue(0.5);
544+
contentpass = new Contentpass({ ...config, samplingRate: 0.4 });
545+
546+
await contentpass.countImpression();
547+
expect(sendStatsSpy).not.toHaveBeenCalled();
548+
expect(sendPageViewEventSpy).not.toHaveBeenCalled();
549+
});
550+
});
441551
});

src/Contentpass.ts

Lines changed: 78 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_SAMPLING_RATE = 0.05;
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,14 @@ 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 = config.samplingRate || DEFAULT_SAMPLING_RATE;
57+
this.instanceId = uuid.v4();
3458
this.authStateStorage = new OidcAuthStateStorage(config.propertyId);
3559
this.config = config;
3660
setSentryExtraAttribute('propertyId', config.propertyId);
@@ -97,6 +121,49 @@ export default class Contentpass {
97121
await this.initialiseAuthState();
98122
};
99123

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+
100167
private initialiseAuthState = async () => {
101168
const authState = await this.authStateStorage.getOidcAuthState();
102169
if (authState) {
@@ -209,4 +276,12 @@ export default class Contentpass {
209276
this.contentpassState = state;
210277
this.contentpassStateObservers.forEach((observer) => observer(state));
211278
};
279+
280+
private hasValidSubscriptionAndAccessToken = () => {
281+
return (
282+
this.contentpassState.state === ContentpassStateType.AUTHENTICATED &&
283+
this.contentpassState.hasValidSubscription &&
284+
this.oidcAuthState?.accessToken
285+
);
286+
};
212287
}

0 commit comments

Comments
 (0)