Skip to content

Commit 4777bf8

Browse files
[FSSDK-12444] getQualifiedSegments helper addition (#326)
1 parent 57d1e67 commit 4777bf8

File tree

3 files changed

+305
-0
lines changed

3 files changed

+305
-0
lines changed

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,6 @@ export {
4343
useDecideForKeysAsync,
4444
useDecideAllAsync,
4545
} from './hooks/index';
46+
47+
// Helpers
48+
export { getQualifiedSegments, type QualifiedSegmentsResult } from './utils/index';

src/utils/helpers.spec.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Copyright 2026, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { describe, it, afterEach, expect, vi } from 'vitest';
17+
import * as utils from './helpers';
18+
19+
describe('getQualifiedSegments', () => {
20+
const odpIntegration = {
21+
key: 'odp',
22+
publicKey: 'test-api-key',
23+
host: 'https://odp.example.com',
24+
};
25+
26+
const makeDatafile = (overrides: Record<string, any> = {}) => ({
27+
integrations: [odpIntegration],
28+
typedAudiences: [
29+
{
30+
conditions: ['or', { match: 'qualified', value: 'seg1' }, { match: 'qualified', value: 'seg2' }],
31+
},
32+
],
33+
...overrides,
34+
});
35+
36+
const mockFetchResponse = (body: any, ok = true) => {
37+
vi.stubGlobal(
38+
'fetch',
39+
vi.fn().mockResolvedValue({
40+
ok,
41+
json: () => Promise.resolve(body),
42+
})
43+
);
44+
};
45+
46+
afterEach(() => {
47+
vi.restoreAllMocks();
48+
vi.unstubAllGlobals();
49+
});
50+
51+
it('returns error when datafile is invalid or missing ODP integration', async () => {
52+
// undefined datafile
53+
// @ts-ignore
54+
let result = await utils.getQualifiedSegments('user-1');
55+
expect(result.segments).toEqual([]);
56+
expect(result.error?.message).toBe('Invalid datafile: expected a JSON string or object');
57+
58+
// invalid JSON string
59+
result = await utils.getQualifiedSegments('user-1', '{bad json');
60+
expect(result.segments).toEqual([]);
61+
expect(result.error?.message).toBe('Invalid datafile: failed to parse JSON string');
62+
63+
// no ODP integration
64+
result = await utils.getQualifiedSegments('user-1', { integrations: [] });
65+
expect(result.segments).toEqual([]);
66+
expect(result.error?.message).toBe('ODP integration not found or missing publicKey/host');
67+
68+
// ODP integration missing publicKey
69+
result = await utils.getQualifiedSegments('user-1', {
70+
integrations: [{ key: 'odp', host: 'https://odp.example.com' }],
71+
});
72+
expect(result.segments).toEqual([]);
73+
expect(result.error?.message).toBe('ODP integration not found or missing publicKey/host');
74+
});
75+
76+
it('returns empty array with no error when ODP is integrated but no segment conditions exist', async () => {
77+
const fetchSpy = vi.spyOn(global, 'fetch');
78+
const datafile = makeDatafile({ typedAudiences: [], audiences: [] });
79+
const result = await utils.getQualifiedSegments('user-1', datafile);
80+
81+
expect(result.segments).toEqual([]);
82+
expect(result.error).toBeNull();
83+
expect(fetchSpy).not.toHaveBeenCalled();
84+
});
85+
86+
it('calls ODP GraphQL API and returns only qualified segments', async () => {
87+
mockFetchResponse({
88+
data: {
89+
customer: {
90+
audiences: {
91+
edges: [{ node: { name: 'seg1', state: 'qualified' } }, { node: { name: 'seg2', state: 'not_qualified' } }],
92+
},
93+
},
94+
},
95+
});
96+
97+
const result = await utils.getQualifiedSegments('user-1', makeDatafile());
98+
99+
expect(result.segments).toEqual(['seg1']);
100+
expect(result.error).toBeNull();
101+
expect(global.fetch).toHaveBeenCalledWith('https://odp.example.com/v3/graphql', {
102+
method: 'POST',
103+
headers: {
104+
'Content-Type': 'application/json',
105+
'x-api-key': 'test-api-key',
106+
},
107+
body: expect.stringContaining('user-1'),
108+
});
109+
});
110+
111+
it('returns error when fetch fails or response is not ok', async () => {
112+
// network error
113+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
114+
let result = await utils.getQualifiedSegments('user-1', makeDatafile());
115+
expect(result.segments).toEqual([]);
116+
expect(result.error?.message).toBe('network error');
117+
118+
// non-200 response
119+
mockFetchResponse({}, false);
120+
result = await utils.getQualifiedSegments('user-1', makeDatafile());
121+
expect(result.segments).toEqual([]);
122+
expect(result.error?.message).toContain('ODP request failed with status');
123+
});
124+
125+
it('skips audiences with malformed conditions string without throwing', async () => {
126+
mockFetchResponse({
127+
data: {
128+
customer: {
129+
audiences: {
130+
edges: [{ node: { name: 'seg1', state: 'qualified' } }],
131+
},
132+
},
133+
},
134+
});
135+
136+
const datafile = makeDatafile({
137+
typedAudiences: [{ conditions: '{bad json' }, { conditions: ['or', { match: 'qualified', value: 'seg1' }] }],
138+
});
139+
140+
const result = await utils.getQualifiedSegments('user-1', datafile);
141+
expect(result.segments).toEqual(['seg1']);
142+
expect(result.error).toBeNull();
143+
});
144+
145+
it('returns error when response contains GraphQL errors or missing edges', async () => {
146+
// GraphQL errors
147+
mockFetchResponse({ errors: [{ message: 'something went wrong' }] });
148+
let result = await utils.getQualifiedSegments('user-1', makeDatafile());
149+
expect(result.segments).toEqual([]);
150+
expect(result.error?.message).toBe('ODP GraphQL error: something went wrong');
151+
152+
// missing edges path
153+
mockFetchResponse({ data: {} });
154+
result = await utils.getQualifiedSegments('user-1', makeDatafile());
155+
expect(result.segments).toEqual([]);
156+
expect(result.error?.message).toBe('ODP response missing audience edges');
157+
});
158+
});

src/utils/helpers.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,147 @@ export function areUsersEqual(user1?: UserInfo, user2?: UserInfo): boolean {
5454

5555
return true;
5656
}
57+
58+
const QUALIFIED = 'qualified';
59+
60+
/**
61+
* Extracts ODP segments from audience conditions in the datafile.
62+
* Looks for conditions with `match: 'qualified'` and collects their values.
63+
*/
64+
function extractSegmentsFromConditions(condition: any): string[] {
65+
if (Array.isArray(condition)) {
66+
return condition.flatMap(extractSegmentsFromConditions);
67+
}
68+
69+
if (condition && typeof condition === 'object' && condition['match'] === QUALIFIED) {
70+
const value = condition['value'];
71+
return typeof value === 'string' && value.length > 0 ? [value] : [];
72+
}
73+
74+
return [];
75+
}
76+
77+
/**
78+
* Builds the GraphQL query payload for fetching audience segments from ODP.
79+
*/
80+
function buildGraphQLQuery(userId: string, segmentsToCheck: string[]): string {
81+
const segmentsList = segmentsToCheck.map((s) => `"${s}"`).join(',');
82+
const query = `query {customer(fs_user_id : "${userId}") {audiences(subset: [${segmentsList}]) {edges {node {name state}}}}}`;
83+
return JSON.stringify({ query });
84+
}
85+
86+
export interface QualifiedSegmentsResult {
87+
segments: string[];
88+
error: Error | null;
89+
}
90+
91+
/**
92+
* Fetches qualified ODP segments for a user given a datafile and user ID.
93+
*
94+
* This is a standalone, self-contained utility that:
95+
* 1. Parses the datafile to extract ODP configuration (apiKey, apiHost)
96+
* 2. Collects all ODP segments referenced in audience conditions
97+
* 3. Queries the ODP GraphQL API
98+
* 4. Returns only the segments where the user is qualified
99+
*
100+
* @param userId - The user ID to fetch qualified segments for
101+
* @param datafile - The Optimizely datafile (JSON object or string)
102+
* @returns Object with `segments` (qualified segment names) and `error` (null on success).
103+
*
104+
* @example
105+
* ```ts
106+
* const { segments, error } = await getQualifiedSegments('user-123', datafile);
107+
* if (!error) {
108+
* console.log('Qualified segments:', segments);
109+
* }
110+
* ```
111+
*/
112+
export async function getQualifiedSegments(
113+
userId: string,
114+
datafile: string | Record<string, any>
115+
): Promise<QualifiedSegmentsResult> {
116+
let datafileObj: any;
117+
118+
if (typeof datafile === 'string') {
119+
try {
120+
datafileObj = JSON.parse(datafile);
121+
} catch {
122+
return { segments: [], error: new Error('Invalid datafile: failed to parse JSON string') };
123+
}
124+
} else if (typeof datafile === 'object' && datafile !== null) {
125+
datafileObj = datafile;
126+
} else {
127+
return { segments: [], error: new Error('Invalid datafile: expected a JSON string or object') };
128+
}
129+
130+
// Extract ODP integration config from datafile
131+
const odpIntegration = Array.isArray(datafileObj.integrations)
132+
? datafileObj.integrations.find((i: Record<string, unknown>) => i.key === 'odp')
133+
: undefined;
134+
135+
const apiKey = odpIntegration?.publicKey;
136+
const apiHost = odpIntegration?.host;
137+
138+
if (!apiKey || !apiHost) {
139+
return { segments: [], error: new Error('ODP integration not found or missing publicKey/host') };
140+
}
141+
142+
// Collect all ODP segments from audience conditions
143+
const allSegments = new Set<string>();
144+
const audiences = [...(datafileObj.audiences || []), ...(datafileObj.typedAudiences || [])];
145+
146+
for (const audience of audiences) {
147+
if (audience.conditions) {
148+
let conditions = audience.conditions;
149+
if (typeof conditions === 'string') {
150+
try {
151+
conditions = JSON.parse(conditions);
152+
} catch {
153+
continue;
154+
}
155+
}
156+
extractSegmentsFromConditions(conditions).forEach((s) => allSegments.add(s));
157+
}
158+
}
159+
160+
const segmentsToCheck = Array.from(allSegments);
161+
if (segmentsToCheck.length === 0) {
162+
return { segments: [], error: null };
163+
}
164+
165+
const endpoint = `${apiHost}/v3/graphql`;
166+
const query = buildGraphQLQuery(userId, segmentsToCheck);
167+
168+
try {
169+
const response = await fetch(endpoint, {
170+
method: 'POST',
171+
headers: {
172+
'Content-Type': 'application/json',
173+
'x-api-key': apiKey,
174+
},
175+
body: query,
176+
});
177+
178+
if (!response.ok) {
179+
return { segments: [], error: new Error(`ODP request failed with status ${response.status}`) };
180+
}
181+
182+
const json = await response.json();
183+
184+
if (json.errors?.length > 0) {
185+
return { segments: [], error: new Error(`ODP GraphQL error: ${json.errors[0].message}`) };
186+
}
187+
188+
const edges = json?.data?.customer?.audiences?.edges;
189+
if (!edges) {
190+
return { segments: [], error: new Error('ODP response missing audience edges') };
191+
}
192+
193+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
194+
const segments = edges.filter((edge: any) => edge.node.state === QUALIFIED).map((edge: any) => edge.node.name);
195+
196+
return { segments, error: null };
197+
} catch (e) {
198+
return { segments: [], error: e instanceof Error ? e : new Error('ODP request failed') };
199+
}
200+
}

0 commit comments

Comments
 (0)