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
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ export {
useDecideForKeysAsync,
useDecideAllAsync,
} from './hooks/index';

// Helpers
export { getQualifiedSegments, type QualifiedSegmentsResult } from './utils/index';
158 changes: 158 additions & 0 deletions src/utils/helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Copyright 2026, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, afterEach, expect, vi } from 'vitest';
import * as utils from './helpers';

describe('getQualifiedSegments', () => {
const odpIntegration = {
key: 'odp',
publicKey: 'test-api-key',
host: 'https://odp.example.com',
};

const makeDatafile = (overrides: Record<string, any> = {}) => ({
integrations: [odpIntegration],
typedAudiences: [
{
conditions: ['or', { match: 'qualified', value: 'seg1' }, { match: 'qualified', value: 'seg2' }],
},
],
...overrides,
});

const mockFetchResponse = (body: any, ok = true) => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok,
json: () => Promise.resolve(body),
})
);
};

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it('returns error when datafile is invalid or missing ODP integration', async () => {
// undefined datafile
// @ts-ignore
let result = await utils.getQualifiedSegments('user-1');
expect(result.segments).toEqual([]);
expect(result.error?.message).toBe('Invalid datafile: expected a JSON string or object');

// invalid JSON string
result = await utils.getQualifiedSegments('user-1', '{bad json');
expect(result.segments).toEqual([]);
expect(result.error?.message).toBe('Invalid datafile: failed to parse JSON string');

// no ODP integration
result = await utils.getQualifiedSegments('user-1', { integrations: [] });
expect(result.segments).toEqual([]);
expect(result.error?.message).toBe('ODP integration not found or missing publicKey/host');

// ODP integration missing publicKey
result = await utils.getQualifiedSegments('user-1', {
integrations: [{ key: 'odp', host: 'https://odp.example.com' }],
});
expect(result.segments).toEqual([]);
expect(result.error?.message).toBe('ODP integration not found or missing publicKey/host');
});

it('returns empty array with no error when ODP is integrated but no segment conditions exist', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
const datafile = makeDatafile({ typedAudiences: [], audiences: [] });
const result = await utils.getQualifiedSegments('user-1', datafile);

expect(result.segments).toEqual([]);
expect(result.error).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});

it('calls ODP GraphQL API and returns only qualified segments', async () => {
mockFetchResponse({
data: {
customer: {
audiences: {
edges: [{ node: { name: 'seg1', state: 'qualified' } }, { node: { name: 'seg2', state: 'not_qualified' } }],
},
},
},
});

const result = await utils.getQualifiedSegments('user-1', makeDatafile());

expect(result.segments).toEqual(['seg1']);
expect(result.error).toBeNull();
expect(global.fetch).toHaveBeenCalledWith('https://odp.example.com/v3/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'test-api-key',
},
body: expect.stringContaining('user-1'),
});
});

it('returns error when fetch fails or response is not ok', async () => {
// network error
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
let result = await utils.getQualifiedSegments('user-1', makeDatafile());
expect(result.segments).toEqual([]);
expect(result.error?.message).toBe('network error');

// non-200 response
mockFetchResponse({}, false);
result = await utils.getQualifiedSegments('user-1', makeDatafile());
expect(result.segments).toEqual([]);
expect(result.error?.message).toContain('ODP request failed with status');
});

it('skips audiences with malformed conditions string without throwing', async () => {
mockFetchResponse({
data: {
customer: {
audiences: {
edges: [{ node: { name: 'seg1', state: 'qualified' } }],
},
},
},
});

const datafile = makeDatafile({
typedAudiences: [{ conditions: '{bad json' }, { conditions: ['or', { match: 'qualified', value: 'seg1' }] }],
});

const result = await utils.getQualifiedSegments('user-1', datafile);
expect(result.segments).toEqual(['seg1']);
expect(result.error).toBeNull();
});

it('returns error when response contains GraphQL errors or missing edges', async () => {
// GraphQL errors
mockFetchResponse({ errors: [{ message: 'something went wrong' }] });
let result = await utils.getQualifiedSegments('user-1', makeDatafile());
expect(result.segments).toEqual([]);
expect(result.error?.message).toBe('ODP GraphQL error: something went wrong');

// missing edges path
mockFetchResponse({ data: {} });
result = await utils.getQualifiedSegments('user-1', makeDatafile());
expect(result.segments).toEqual([]);
expect(result.error?.message).toBe('ODP response missing audience edges');
});
});
144 changes: 144 additions & 0 deletions src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,147 @@ export function areUsersEqual(user1?: UserInfo, user2?: UserInfo): boolean {

return true;
}

const QUALIFIED = 'qualified';

/**
* Extracts ODP segments from audience conditions in the datafile.
* Looks for conditions with `match: 'qualified'` and collects their values.
*/
function extractSegmentsFromConditions(condition: any): string[] {
if (Array.isArray(condition)) {
return condition.flatMap(extractSegmentsFromConditions);
}

if (condition && typeof condition === 'object' && condition['match'] === QUALIFIED) {
const value = condition['value'];
return typeof value === 'string' && value.length > 0 ? [value] : [];
}

return [];
}

/**
* Builds the GraphQL query payload for fetching audience segments from ODP.
*/
function buildGraphQLQuery(userId: string, segmentsToCheck: string[]): string {
const segmentsList = segmentsToCheck.map((s) => `"${s}"`).join(',');
const query = `query {customer(fs_user_id : "${userId}") {audiences(subset: [${segmentsList}]) {edges {node {name state}}}}}`;
return JSON.stringify({ query });
Comment on lines +81 to +83
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildGraphQLQuery interpolates userId and segment names directly into the GraphQL source. If either contains quotes/backslashes/newlines, the query becomes invalid, and this also opens the door to GraphQL injection (the query text is constructed from untrusted input). Prefer sending a static query with GraphQL variables (e.g., variables: { userId, subset: segmentsToCheck }) so values are encoded safely, rather than embedding them into the query string.

Suggested change
const segmentsList = segmentsToCheck.map((s) => `"${s}"`).join(',');
const query = `query {customer(fs_user_id : "${userId}") {audiences(subset: [${segmentsList}]) {edges {node {name state}}}}}`;
return JSON.stringify({ query });
const query =
'query GetQualifiedSegments($userId: String!, $subset: [String!]!) {' +
' customer(fs_user_id: $userId) {' +
' audiences(subset: $subset) {' +
' edges {' +
' node {' +
' name state' +
' }' +
' }' +
' }' +
' }' +
'}';
return JSON.stringify({
query,
variables: {
userId,
subset: segmentsToCheck,
},
});

Copilot uses AI. Check for mistakes.
}

export interface QualifiedSegmentsResult {
segments: string[];
error: Error | null;
}

/**
* Fetches qualified ODP segments for a user given a datafile and user ID.
*
* This is a standalone, self-contained utility that:
* 1. Parses the datafile to extract ODP configuration (apiKey, apiHost)
* 2. Collects all ODP segments referenced in audience conditions
* 3. Queries the ODP GraphQL API
* 4. Returns only the segments where the user is qualified
*
* @param userId - The user ID to fetch qualified segments for
* @param datafile - The Optimizely datafile (JSON object or string)
* @returns Object with `segments` (qualified segment names) and `error` (null on success).
*
* @example
* ```ts
* const { segments, error } = await getQualifiedSegments('user-123', datafile);
* if (!error) {
* console.log('Qualified segments:', segments);
* }
* ```
*/
export async function getQualifiedSegments(
userId: string,
datafile: string | Record<string, any>
): Promise<QualifiedSegmentsResult> {
let datafileObj: any;

if (typeof datafile === 'string') {
try {
datafileObj = JSON.parse(datafile);
} catch {
return { segments: [], error: new Error('Invalid datafile: failed to parse JSON string') };
}
} else if (typeof datafile === 'object' && datafile !== null) {
datafileObj = datafile;
} else {
return { segments: [], error: new Error('Invalid datafile: expected a JSON string or object') };
}

// Extract ODP integration config from datafile
const odpIntegration = Array.isArray(datafileObj.integrations)
? datafileObj.integrations.find((i: Record<string, unknown>) => i.key === 'odp')
: undefined;

const apiKey = odpIntegration?.publicKey;
const apiHost = odpIntegration?.host;

if (!apiKey || !apiHost) {
return { segments: [], error: new Error('ODP integration not found or missing publicKey/host') };
}
Comment on lines +112 to +140
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getQualifiedSegments returns null on non-integrated/failed fetch, but the Provider’s qualifiedSegments?: string[] contract treats undefined as “normal flow” and assumes any provided value is an array (see src/utils/UserContextManager.ts:107-123, which spreads qualifiedSegments). If callers pass this helper’s null directly into qualifiedSegments, it can cause a runtime TypeError. Consider changing the return type to string[] | undefined (return undefined instead of null), or otherwise ensure consumers can’t pass null through to the Provider APIs.

Copilot uses AI. Check for mistakes.

// Collect all ODP segments from audience conditions
const allSegments = new Set<string>();
const audiences = [...(datafileObj.audiences || []), ...(datafileObj.typedAudiences || [])];

for (const audience of audiences) {
if (audience.conditions) {
let conditions = audience.conditions;
if (typeof conditions === 'string') {
try {
conditions = JSON.parse(conditions);
} catch {
continue;
}
}
extractSegmentsFromConditions(conditions).forEach((s) => allSegments.add(s));
}
}

const segmentsToCheck = Array.from(allSegments);
if (segmentsToCheck.length === 0) {
return { segments: [], error: null };
}

const endpoint = `${apiHost}/v3/graphql`;
const query = buildGraphQLQuery(userId, segmentsToCheck);

try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: query,
});

if (!response.ok) {
return { segments: [], error: new Error(`ODP request failed with status ${response.status}`) };
}

const json = await response.json();

if (json.errors?.length > 0) {
return { segments: [], error: new Error(`ODP GraphQL error: ${json.errors[0].message}`) };
}

const edges = json?.data?.customer?.audiences?.edges;
if (!edges) {
return { segments: [], error: new Error('ODP response missing audience edges') };
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const segments = edges.filter((edge: any) => edge.node.state === QUALIFIED).map((edge: any) => edge.node.name);

return { segments, error: null };
} catch (e) {
return { segments: [], error: e instanceof Error ? e : new Error('ODP request failed') };
}
}
Loading