-
Notifications
You must be signed in to change notification settings - Fork 37
[FSSDK-12444] getQualifiedSegments helper addition #326
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6d634b7
fe0ec2a
46ff75d
831d536
b5743e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| }); | ||
junaed-optimizely marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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'); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||||||||||
| 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
AI
Apr 8, 2026
There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.