Skip to content
Open
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
11 changes: 11 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ const compat = new FlatCompat({
});

export default [
{
// Generated files: skip linting. `types.gen.ts` and the companion
// `config/index.ts` re-export are regenerated by @hey-api/openapi-ts
// from the serving OpenAPI spec, so any prettier cleanup would be
// wiped on the next regen. The local override for ExperienceTypes
// uses a deliberately different multi-line shape.
ignores: [
'**/packages/types/src/config/types.gen.ts',
'**/packages/types/src/config/index.ts'
]
},
{
files: ['**/*.ts', '**/*.tsx'],
ignores: ['.yarn', '.vscode', '.github', 'node_modules']
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"packageManager": "yarn@4.5.3",
"scripts": {
"api:lint": "cd packages/api && yarn lint",
"bucketing:lint": "cd packages/bucketing && yarn lint",
Expand Down
59 changes: 47 additions & 12 deletions packages/data/src/data-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
ConfigAudienceTypes,
VariationStatuses,
eventType,
GenericListMatchingOptions
GenericListMatchingOptions,
RuleDataProvider
} from '@convertcom/js-sdk-types';

import {
Expand Down Expand Up @@ -83,6 +84,7 @@
private _asyncStorage: boolean;
private _environment: string;
private _mapper: (...args: any) => any;
private _ruleDataProvider: RuleDataProvider | null;

Check warning on line 87 in packages/data/src/data-manager.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member '_ruleDataProvider' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=convertcom_javascript-sdk&issues=AZ47NNILIKrgnONvWDIp&open=AZ47NNILIKrgnONvWDIp&pullRequest=377
/**
* @param {Config} config
* @param {Object} dependencies
Expand Down Expand Up @@ -121,6 +123,23 @@
this._config = config;
this._mapper = config?.mapper || ((value: any) => value);
this._asyncStorage = asyncStorage;
this._ruleDataProvider = config?.ruleDataProvider || null;
// Guard: a misconfigured provider (missing the `name: 'RuleData'`
// discriminator) falls through RuleManager's "flat-key" branch and
// silently returns false for every rule — i.e. no audiences match,
// no experiences run, no errors thrown. Warn loudly at construction
// so the misconfiguration surfaces immediately instead of as silent
// data loss across the entire visitor population.
if (
this._ruleDataProvider &&
!this._ruleManager.isUsingCustomInterface(this._ruleDataProvider)
) {
this._loggerManager?.warn?.(
'DataManager()',
ERROR_MESSAGES.RULE_DATA_PROVIDER_INVALID
);
this._ruleDataProvider = null;
}
this._data = objectDeepValue(config, 'data');
this._accountId = this._data?.account_id;
this._projectId = this._data?.project?.id;
Expand Down Expand Up @@ -288,10 +307,13 @@
isBucketed = true;
}

// Check location rules against locationProperties
// Check location rules against locationProperties.
// Enter the eval block if EITHER a per-call locationProperties OR a
// globally-configured ruleDataProvider is available — the outer gate
// would otherwise skip the eval when only the provider is set.
let locationMatched: boolean | RuleError =
ignoreLocationProperties === true;
if (!locationMatched && locationProperties) {
if (!locationMatched && (locationProperties || this._ruleDataProvider)) {
if (Array.isArray(experience?.locations) && experience.locations.length) {
let matchedLocations = [];
// Get attached locations
Expand All @@ -317,7 +339,7 @@
} else if (experience?.site_area) {
// Validate locationProperties against site area rules
locationMatched = this._ruleManager.isRuleMatched(
locationProperties,
locationProperties || this._ruleDataProvider,
experience.site_area,
'SiteArea'
);
Expand Down Expand Up @@ -347,15 +369,18 @@
return null;
}

// Check audience rules against visitorProperties
// Check audience rules against visitorProperties.
// Same gate-broadening as above: enter the eval block if either a
// per-call visitorProperties OR a global ruleDataProvider exists,
// otherwise the provider is unreachable from the audience path.
let audiences = [],
segments = [],
matchedAudiences = [],
matchedSegments = [],
audiencesToCheck: Array<ConfigAudience> = [],
audiencesMatched = false,
segmentsMatched = false;
if (visitorProperties) {
if (visitorProperties || this._ruleDataProvider) {
if (Array.isArray(experience?.audiences) && experience.audiences.length) {
// Get attached transient and/or permnent audiences
audiences = this.getItemsByIds(
Expand Down Expand Up @@ -709,7 +734,8 @@
...{
experienceId: experience?.id,
experienceName: experience?.name,
experienceKey: experience?.key
experienceKey: experience?.key,
experienceType: experience?.type
},
bucketingAllocation,
...variation
Expand Down Expand Up @@ -856,7 +882,7 @@
for (let i = 0, length = items.length; i < length; i++) {
if (!items?.[i]?.rules) continue;
match = this._ruleManager.isRuleMatched(
locationProperties,
locationProperties || this._ruleDataProvider,
items[i].rules,
`ConfigLocation #${items[i][identityField]}`
);
Expand Down Expand Up @@ -977,7 +1003,7 @@
* @param {VisitorSegments} segments
* @param {Record<ConversionSettingKey, number | string | boolean>} conversionSetting An object of key-value pairs that are used for tracking settings
*/
convert(

Check failure on line 1006 in packages/data/src/data-manager.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=convertcom_javascript-sdk&issues=AZ4cOP8Sk1zz5HcmzuoU&open=AZ4cOP8Sk1zz5HcmzuoU&pullRequest=377
visitorId: string,
goalId: string,
goalRule?: Record<string, any>,
Expand All @@ -997,10 +1023,19 @@
return;
}

if (goalRule) {
if (!goal?.rules) return;
// Two separate concerns:
// 1. Pre-PR contract: if the caller explicitly passes `goalRule` to a
// goal that itself has no rules, the conversion is a no-op. This
// surfaces the call-site misunderstanding instead of firing
// silently. Preserved here.
// 2. PR addition: a globally-configured `ruleDataProvider` should
// ONLY participate in rule evaluation when the goal actually has
// rules. Otherwise rule-less goals would be silently dropped on
// every visitor when a provider is set.
if (goalRule && !goal?.rules) return;
if (goal?.rules && (goalRule || this._ruleDataProvider)) {
const ruleMatched = this._ruleManager.isRuleMatched(
goalRule,
goalRule || this._ruleDataProvider,
goal.rules,
`ConfigGoal #${goalId}`
);
Expand Down Expand Up @@ -1112,7 +1147,7 @@
for (let i = 0, length = items.length; i < length; i++) {
if (!items?.[i]?.rules) continue;
match = this._ruleManager.isRuleMatched(
visitorProperties,
visitorProperties || this._ruleDataProvider,
items[i].rules,
`${camelCase(entityType)} #${items[i][field]}`
);
Expand Down
178 changes: 173 additions & 5 deletions packages/data/tests/data-manager.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import testConfig from './test-config.json';
import {Config as ConfigType} from '@convertcom/js-sdk-types';
import {objectDeepMerge} from '@convertcom/js-sdk-utils';
import {defaultConfig} from '../../js-sdk/src/config/default';
import {awaitTrackRequest} from '../../js-sdk/tests/setup/track-request';

class DataStore {
data = {};
Expand Down Expand Up @@ -71,7 +72,7 @@ describe('DataManager tests', function () {
customSegments: ['seg1', 'seg2']
};
let dataManager, accountId, projectId, storeKey, server;
// eslint-disable-next-line mocha/no-hooks-for-single-case

before(function () {
accountId = configuration?.data?.account_id;
projectId = configuration?.data?.project?.id;
Expand All @@ -83,12 +84,12 @@ describe('DataManager tests', function () {
apiManager
});
});
// eslint-disable-next-line mocha/no-hooks-for-single-case

beforeEach(function () {
server = http.createServer();
server.listen(port);
});
// eslint-disable-next-line mocha/no-hooks-for-single-case

afterEach(function () {
dataManager.reset();
server.closeAllConnections();
Expand Down Expand Up @@ -307,7 +308,6 @@ describe('DataManager tests', function () {
});
});
describe('Persistent Data Store enqueue tests', function () {
// eslint-disable-next-line mocha/no-hooks-for-single-case
before(function () {
configuration.dataStore = dataStore;
dataManager = new dm(
Expand Down Expand Up @@ -357,7 +357,6 @@ describe('DataManager tests', function () {
});
});
describe('Persistent Data Store tests (set immediately)', function () {
// eslint-disable-next-line mocha/no-hooks-for-single-case
before(function () {
dataStore.data = {};
delete configuration.dataStore;
Expand Down Expand Up @@ -398,4 +397,173 @@ describe('DataManager tests', function () {
expect(check).to.have.property('segments').that.deep.equal(segments);
});
});
describe('Test ruleDataProvider integration', function () {
let receivedRuleData;
let provider;

before(function () {
// Mock RuleManager that captures what data shape was passed to isRuleMatched
const capturingRuleManager: any = {
isRuleMatched: (data) => {
receivedRuleData = data;
return true;
},
isUsingCustomInterface: (data) => !!data && data.name === 'RuleData'
};
provider = {
name: 'RuleData',
getGenericTextKeyValue: () => 'something'
};
const configWithProvider = objectDeepMerge(configuration, {
ruleDataProvider: provider,
dataStore: undefined
}) as unknown as ConfigType;
dataManager = new dm(configWithProvider, {
bucketingManager,
ruleManager: capturingRuleManager,
eventManager,
apiManager
});
});
beforeEach(function () {
receivedRuleData = undefined;
});
it('Should use ruleDataProvider when no per-call visitorProperties is supplied', async function () {
// Per-call wins, so when the caller omits visitorProperties the
// global provider supplies the rule data.
this.timeout(test_timeout);
const experienceKey = 'test-experience-ab-fullstack-2';
dataManager.getBucketing(visitorId, experienceKey, {
locationProperties: {url: 'https://convert.com/'}
});
await awaitTrackRequest(server, `/track/${accountId}/${projectId}`);
expect(receivedRuleData)
.to.be.an('object')
.that.has.property('name', 'RuleData');
expect(receivedRuleData).to.equal(provider);
});
it('Should let per-call visitorProperties win over a configured ruleDataProvider', async function () {
// Precedence guard: a caller who explicitly supplies
// visitorProperties is opting out of the provider for that call,
// matching standard config-vs-args layering.
this.timeout(test_timeout);
const experienceKey = 'test-experience-ab-fullstack-2';
dataManager.getBucketing(visitorId, experienceKey, {
visitorProperties: {varName3: 'plain-value'},
locationProperties: {url: 'https://convert.com/'}
});
await awaitTrackRequest(server, `/track/${accountId}/${projectId}`);
expect(receivedRuleData).to.be.an('object');
expect(receivedRuleData).to.not.have.property('name', 'RuleData');
expect(receivedRuleData).to.have.property('varName3', 'plain-value');
});
it('Should fall back to plain visitorProperties when no ruleDataProvider is set', async function () {
this.timeout(test_timeout);
const noProviderConfig = objectDeepMerge(configuration, {
ruleDataProvider: undefined,
dataStore: undefined
}) as unknown as ConfigType;
const capturingRuleManager: any = {
isRuleMatched: (data) => {
receivedRuleData = data;
return true;
},
isUsingCustomInterface: (data) => !!data && data.name === 'RuleData'
};
const localDataManager = new dm(noProviderConfig, {
bucketingManager,
ruleManager: capturingRuleManager,
eventManager,
apiManager
});
const experienceKey = 'test-experience-ab-fullstack-2';
localDataManager.getBucketing(visitorId, experienceKey, {
visitorProperties: {varName3: 'plain-value'},
locationProperties: {url: 'https://convert.com/'}
});
await awaitTrackRequest(server, `/track/${accountId}/${projectId}`);
// No provider configured — RuleManager should see the plain object.
expect(receivedRuleData).to.be.an('object');
expect(receivedRuleData).to.not.have.property('name', 'RuleData');
expect(receivedRuleData).to.have.property('varName3', 'plain-value');
});
it('Should warn and ignore a misconfigured ruleDataProvider (missing name)', function () {
// A provider that doesn't satisfy isUsingCustomInterface (no
// `name: 'RuleData'`) would otherwise fall through RuleManager's
// flat-key branch and silently return false for every rule. The
// DataManager constructor must surface the misconfiguration as a
// warn and ignore the provider so the SDK falls back to plain
// visitor/location properties.
const warns: Array<Array<any>> = [];
const capturingLogger: any = {
warn: (...args: any[]) => warns.push(args),
error: () => {},
info: () => {},
debug: () => {},
trace: () => {}
};
const badProvider = {country: 'CA'}; // missing name: 'RuleData'
const badConfig = objectDeepMerge(configuration, {
ruleDataProvider: badProvider,
dataStore: undefined
}) as unknown as ConfigType;
const localDataManager = new dm(badConfig, {
bucketingManager,
ruleManager,
eventManager,
apiManager,
loggerManager: capturingLogger
});
assert.isDefined(localDataManager);
const warned = warns.some((entry) =>
entry.some(
(a) =>
typeof a === 'string' &&
a.includes('Config.ruleDataProvider is set')
)
);
expect(warned).to.equal(true);
});
it('Should fire conversion for a rule-less goal even when ruleDataProvider is configured', async function () {
// Regression guard: if convert() always enters the rule-evaluation
// branch when a global ruleDataProvider is set, it would hit
// `if (!goal?.rules) return;` and silently drop every conversion for
// goals that have no rules. The fix gates on `goal.rules` first.
this.timeout(test_timeout);
const ruleLessGoal = {
id: '90000001',
name: 'Rule-less goal',
key: 'rule-less-goal',
type: 'event'
// intentionally no `rules` field
};
const configWithProvider = objectDeepMerge(configuration, {
ruleDataProvider: {
name: 'RuleData',
getUrl: () => 'https://convert.com/'
},
dataStore: undefined,
data: {
...configuration.data,
goals: [...configuration.data.goals, ruleLessGoal]
}
}) as unknown as ConfigType;
const localDataManager = new dm(configWithProvider, {
bucketingManager,
ruleManager,
eventManager,
apiManager
});
const triggered = localDataManager.convert(
visitorId,
ruleLessGoal.key,
undefined, // no goalRule passed
undefined,
{}
);
await awaitTrackRequest(server, `/track/${accountId}/${projectId}`);
// convert() returns `true` when the conversion fires
expect(triggered).to.equal(true);
});
});
});
Loading
Loading