From 6d634b74640873c835a9a3dde00e82620cd83826 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:46:34 +0600 Subject: [PATCH 1/3] [FSSDK-12444] getQualifiedSegments helper addition --- src/index.ts | 3 + src/utils/helpers.spec.ts | 119 +++++++++++++++++++++++++++++++ src/utils/helpers.ts | 143 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 src/utils/helpers.spec.ts diff --git a/src/index.ts b/src/index.ts index 9716b69..c8a54ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,3 +43,6 @@ export { useDecideForKeysAsync, useDecideAllAsync, } from './hooks/index'; + +// Helpers +export { getQualifiedSegments } from './utils/index'; diff --git a/src/utils/helpers.spec.ts b/src/utils/helpers.spec.ts new file mode 100644 index 0000000..70e5670 --- /dev/null +++ b/src/utils/helpers.spec.ts @@ -0,0 +1,119 @@ +/** + * 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 = {}) => ({ + integrations: [odpIntegration], + typedAudiences: [ + { + conditions: ['or', { match: 'qualified', value: 'seg1' }, { match: 'qualified', value: 'seg2' }], + }, + ], + ...overrides, + }); + + const mockFetchResponse = (body: any, ok = true) => { + global.fetch = vi.fn().mockResolvedValue({ + ok, + json: () => Promise.resolve(body), + }); + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null when datafile is invalid or missing ODP integration', async () => { + // undefined datafile + // @ts-ignore + expect(await utils.getQualifiedSegments('user-1')).toBeNull(); + // invalid JSON string + expect(await utils.getQualifiedSegments('user-1', '{bad json')).toBeNull(); + // no ODP integration + expect(await utils.getQualifiedSegments('user-1', { integrations: [] })).toBeNull(); + // ODP integration missing publicKey + expect( + await utils.getQualifiedSegments('user-1', { + integrations: [{ key: 'odp', host: 'https://odp.example.com' }], + }) + ).toBeNull(); + }); + + it('returns empty array 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).toEqual([]); + 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).toEqual(['seg1']); + 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 null when fetch fails or response is not ok', async () => { + // network error + global.fetch = vi.fn().mockRejectedValue(new Error('network error')); + + expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + + // non-200 response + mockFetchResponse({}, false); + + expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + }); + + it('returns null when response contains GraphQL errors or missing edges', async () => { + // GraphQL errors + mockFetchResponse({ errors: [{ message: 'something went wrong' }] }); + + expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + + // missing edges path + mockFetchResponse({ data: {} }); + + expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + }); +}); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 6414b3e..9d5d880 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -54,3 +54,146 @@ 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 (typeof condition === 'string') { + return []; + } + + if (Array.isArray(condition)) { + const segments: string[] = []; + condition.forEach((c) => segments.push(...extractSegmentsFromConditions(c))); + return segments; + } + + if (condition && typeof condition === 'object' && condition['match'] === 'qualified') { + return [condition['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(','); + return `{"query" : "query {customer(fs_user_id : \\"${userId}\\") {audiences(subset: [${segmentsList}]) {edges {node {name state}}}}}"}`; +} + +/** + * 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 Array of qualified segment names, empty array if no segments configured, + * or null if ODP is not integrated or the fetch fails. + * + * @example + * ```ts + * const segments = await getQualifiedSegments('user-123', datafile); + * if (segments) { + * console.log('Qualified segments:', segments); + * } + * ``` + */ +export async function getQualifiedSegments( + userId: string, + datafile: string | Record +): Promise { + let datafileObj: any; + + if (typeof datafile === 'string') { + try { + datafileObj = JSON.parse(datafile); + } catch { + return null; + } + } else if (typeof datafile === 'object') { + datafileObj = datafile; + } else { + return null; + } + + // Extract ODP integration config from datafile + let apiKey = ''; + let apiHost = ''; + let odpIntegrated = false; + + if (Array.isArray(datafileObj.integrations)) { + for (const integration of datafileObj.integrations) { + if (integration.key === 'odp') { + odpIntegrated = true; + apiKey = integration.publicKey || ''; + apiHost = integration.host || ''; + break; + } + } + } + + if (!odpIntegrated || !apiKey || !apiHost) { + return null; + } + + // Collect all ODP segments from audience conditions + const allSegments = new Set(); + const audiences = [...(datafileObj.audiences || []), ...(datafileObj.typedAudiences || [])]; + + for (const audience of audiences) { + if (audience.conditions) { + const conditions = + typeof audience.conditions === 'string' ? JSON.parse(audience.conditions) : audience.conditions; + extractSegmentsFromConditions(conditions).forEach((s) => allSegments.add(s)); + } + } + + const segmentsToCheck = Array.from(allSegments); + if (segmentsToCheck.length === 0) { + return []; + } + + 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 null; + } + + const json = await response.json(); + + if (json.errors?.length > 0) { + return null; + } + + const edges = json?.data?.customer?.audiences?.edges; + if (!edges) { + return null; + } + + return edges.filter((edge: any) => edge.node.state === QUALIFIED).map((edge: any) => edge.node.name); + } catch { + return null; + } +} From fe0ec2aeee24f3bb670f1b7be1b5c542f3f38943 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:26:10 +0600 Subject: [PATCH 2/3] [FSSDK-12444] getQualifiedSegments improvement --- src/utils/helpers.spec.ts | 33 +++++++++++++++++++++---- src/utils/helpers.ts | 52 +++++++++++++++++---------------------- 2 files changed, 51 insertions(+), 34 deletions(-) diff --git a/src/utils/helpers.spec.ts b/src/utils/helpers.spec.ts index 70e5670..83812e0 100644 --- a/src/utils/helpers.spec.ts +++ b/src/utils/helpers.spec.ts @@ -34,14 +34,18 @@ describe('getQualifiedSegments', () => { }); const mockFetchResponse = (body: any, ok = true) => { - global.fetch = vi.fn().mockResolvedValue({ - ok, - json: () => Promise.resolve(body), - }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok, + json: () => Promise.resolve(body), + }) + ); }; afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it('returns null when datafile is invalid or missing ODP integration', async () => { @@ -95,7 +99,7 @@ describe('getQualifiedSegments', () => { it('returns null when fetch fails or response is not ok', async () => { // network error - global.fetch = vi.fn().mockRejectedValue(new Error('network error')); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))); expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); @@ -105,6 +109,25 @@ describe('getQualifiedSegments', () => { expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); }); + 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).toEqual(['seg1']); + }); + it('returns null when response contains GraphQL errors or missing edges', async () => { // GraphQL errors mockFetchResponse({ errors: [{ message: 'something went wrong' }] }); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 9d5d880..e190f51 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -62,18 +62,13 @@ const QUALIFIED = 'qualified'; * Looks for conditions with `match: 'qualified'` and collects their values. */ function extractSegmentsFromConditions(condition: any): string[] { - if (typeof condition === 'string') { - return []; - } - if (Array.isArray(condition)) { - const segments: string[] = []; - condition.forEach((c) => segments.push(...extractSegmentsFromConditions(c))); - return segments; + return condition.flatMap(extractSegmentsFromConditions); } - if (condition && typeof condition === 'object' && condition['match'] === 'qualified') { - return [condition['value']]; + if (condition && typeof condition === 'object' && condition['match'] === QUALIFIED) { + const value = condition['value']; + return typeof value === 'string' && value.length > 0 ? [value] : []; } return []; @@ -83,8 +78,9 @@ function extractSegmentsFromConditions(condition: any): string[] { * 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(','); - return `{"query" : "query {customer(fs_user_id : \\"${userId}\\") {audiences(subset: [${segmentsList}]) {edges {node {name state}}}}}"}`; + 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 }); } /** @@ -121,29 +117,21 @@ export async function getQualifiedSegments( } catch { return null; } - } else if (typeof datafile === 'object') { + } else if (typeof datafile === 'object' && datafile !== null) { datafileObj = datafile; } else { return null; } // Extract ODP integration config from datafile - let apiKey = ''; - let apiHost = ''; - let odpIntegrated = false; - - if (Array.isArray(datafileObj.integrations)) { - for (const integration of datafileObj.integrations) { - if (integration.key === 'odp') { - odpIntegrated = true; - apiKey = integration.publicKey || ''; - apiHost = integration.host || ''; - break; - } - } - } + const odpIntegration = Array.isArray(datafileObj.integrations) + ? datafileObj.integrations.find((i: Record) => i.key === 'odp') + : undefined; - if (!odpIntegrated || !apiKey || !apiHost) { + const apiKey = odpIntegration?.publicKey; + const apiHost = odpIntegration?.host; + + if (!apiKey || !apiHost) { return null; } @@ -153,8 +141,14 @@ export async function getQualifiedSegments( for (const audience of audiences) { if (audience.conditions) { - const conditions = - typeof audience.conditions === 'string' ? JSON.parse(audience.conditions) : audience.conditions; + let conditions = audience.conditions; + if (typeof conditions === 'string') { + try { + conditions = JSON.parse(conditions); + } catch { + continue; + } + } extractSegmentsFromConditions(conditions).forEach((s) => allSegments.add(s)); } } From 831d5369cb274022625968c9fc9587cc510ed25b Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:54:04 +0600 Subject: [PATCH 3/3] [FSSDK-12444] getQualifiedSegments improvement --- src/index.ts | 2 +- src/utils/helpers.spec.ts | 62 ++++++++++++++++++++++++--------------- src/utils/helpers.ts | 37 +++++++++++++---------- 3 files changed, 62 insertions(+), 39 deletions(-) diff --git a/src/index.ts b/src/index.ts index c8a54ed..6d916b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,4 +45,4 @@ export { } from './hooks/index'; // Helpers -export { getQualifiedSegments } from './utils/index'; +export { getQualifiedSegments, type QualifiedSegmentsResult } from './utils/index'; diff --git a/src/utils/helpers.spec.ts b/src/utils/helpers.spec.ts index 83812e0..5ea5a1c 100644 --- a/src/utils/helpers.spec.ts +++ b/src/utils/helpers.spec.ts @@ -48,28 +48,38 @@ describe('getQualifiedSegments', () => { vi.unstubAllGlobals(); }); - it('returns null when datafile is invalid or missing ODP integration', async () => { + it('returns error when datafile is invalid or missing ODP integration', async () => { // undefined datafile // @ts-ignore - expect(await utils.getQualifiedSegments('user-1')).toBeNull(); + 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 - expect(await utils.getQualifiedSegments('user-1', '{bad json')).toBeNull(); + 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 - expect(await utils.getQualifiedSegments('user-1', { integrations: [] })).toBeNull(); + 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 - expect( - await utils.getQualifiedSegments('user-1', { - integrations: [{ key: 'odp', host: 'https://odp.example.com' }], - }) - ).toBeNull(); + 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 when ODP is integrated but no segment conditions exist', async () => { + 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).toEqual([]); + expect(result.segments).toEqual([]); + expect(result.error).toBeNull(); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -86,7 +96,8 @@ describe('getQualifiedSegments', () => { const result = await utils.getQualifiedSegments('user-1', makeDatafile()); - expect(result).toEqual(['seg1']); + expect(result.segments).toEqual(['seg1']); + expect(result.error).toBeNull(); expect(global.fetch).toHaveBeenCalledWith('https://odp.example.com/v3/graphql', { method: 'POST', headers: { @@ -97,16 +108,18 @@ describe('getQualifiedSegments', () => { }); }); - it('returns null when fetch fails or response is not ok', async () => { + it('returns error when fetch fails or response is not ok', async () => { // network error vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))); - - expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + let result = await utils.getQualifiedSegments('user-1', makeDatafile()); + expect(result.segments).toEqual([]); + expect(result.error?.message).toBe('network error'); // non-200 response mockFetchResponse({}, false); - - expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + 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 () => { @@ -125,18 +138,21 @@ describe('getQualifiedSegments', () => { }); const result = await utils.getQualifiedSegments('user-1', datafile); - expect(result).toEqual(['seg1']); + expect(result.segments).toEqual(['seg1']); + expect(result.error).toBeNull(); }); - it('returns null when response contains GraphQL errors or missing edges', async () => { + it('returns error when response contains GraphQL errors or missing edges', async () => { // GraphQL errors mockFetchResponse({ errors: [{ message: 'something went wrong' }] }); - - expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + 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: {} }); - - expect(await utils.getQualifiedSegments('user-1', makeDatafile())).toBeNull(); + result = await utils.getQualifiedSegments('user-1', makeDatafile()); + expect(result.segments).toEqual([]); + expect(result.error?.message).toBe('ODP response missing audience edges'); }); }); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index e190f51..aa5abb1 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -83,6 +83,11 @@ function buildGraphQLQuery(userId: string, segmentsToCheck: string[]): string { return JSON.stringify({ query }); } +export interface QualifiedSegmentsResult { + segments: string[]; + error: Error | null; +} + /** * Fetches qualified ODP segments for a user given a datafile and user ID. * @@ -94,13 +99,12 @@ function buildGraphQLQuery(userId: string, segmentsToCheck: string[]): string { * * @param userId - The user ID to fetch qualified segments for * @param datafile - The Optimizely datafile (JSON object or string) - * @returns Array of qualified segment names, empty array if no segments configured, - * or null if ODP is not integrated or the fetch fails. + * @returns Object with `segments` (qualified segment names) and `error` (null on success). * * @example * ```ts - * const segments = await getQualifiedSegments('user-123', datafile); - * if (segments) { + * const { segments, error } = await getQualifiedSegments('user-123', datafile); + * if (!error) { * console.log('Qualified segments:', segments); * } * ``` @@ -108,19 +112,19 @@ function buildGraphQLQuery(userId: string, segmentsToCheck: string[]): string { export async function getQualifiedSegments( userId: string, datafile: string | Record -): Promise { +): Promise { let datafileObj: any; if (typeof datafile === 'string') { try { datafileObj = JSON.parse(datafile); } catch { - return null; + return { segments: [], error: new Error('Invalid datafile: failed to parse JSON string') }; } } else if (typeof datafile === 'object' && datafile !== null) { datafileObj = datafile; } else { - return null; + return { segments: [], error: new Error('Invalid datafile: expected a JSON string or object') }; } // Extract ODP integration config from datafile @@ -132,7 +136,7 @@ export async function getQualifiedSegments( const apiHost = odpIntegration?.host; if (!apiKey || !apiHost) { - return null; + return { segments: [], error: new Error('ODP integration not found or missing publicKey/host') }; } // Collect all ODP segments from audience conditions @@ -155,7 +159,7 @@ export async function getQualifiedSegments( const segmentsToCheck = Array.from(allSegments); if (segmentsToCheck.length === 0) { - return []; + return { segments: [], error: null }; } const endpoint = `${apiHost}/v3/graphql`; @@ -172,22 +176,25 @@ export async function getQualifiedSegments( }); if (!response.ok) { - return null; + return { segments: [], error: new Error(`ODP request failed with status ${response.status}`) }; } const json = await response.json(); if (json.errors?.length > 0) { - return null; + return { segments: [], error: new Error(`ODP GraphQL error: ${json.errors[0].message}`) }; } const edges = json?.data?.customer?.audiences?.edges; if (!edges) { - return null; + return { segments: [], error: new Error('ODP response missing audience edges') }; } - return edges.filter((edge: any) => edge.node.state === QUALIFIED).map((edge: any) => edge.node.name); - } catch { - return null; + // 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') }; } }