diff --git a/src/index.ts b/src/index.ts index fd6fd16..f8a8039 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,3 +43,6 @@ export { useDecideForKeysAsync, useDecideAllAsync, } from './hooks/index'; + +// Helpers +export { getQualifiedSegments, type QualifiedSegmentsResult } from './utils/index'; diff --git a/src/utils/helpers.spec.ts b/src/utils/helpers.spec.ts new file mode 100644 index 0000000..5ea5a1c --- /dev/null +++ b/src/utils/helpers.spec.ts @@ -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 = {}) => ({ + 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'); + }); +}); diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 6414b3e..aa5abb1 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -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 }); +} + +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 +): Promise { + 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) => 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') }; + } + + // 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) { + 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') }; + } +}