Skip to content

Commit 3bd5658

Browse files
[FSSDK-12274] ucm update + test
1 parent 37d5769 commit 3bd5658

2 files changed

Lines changed: 269 additions & 3 deletions

File tree

src/utils/UserContextManager.spec.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function createMockClient(opts: MockClientOptions = {}) {
5050
const mockUserContext = {
5151
fetchQualifiedSegments: vi.fn().mockResolvedValue(fetchQualifiedSegmentsResult),
5252
getUserId: vi.fn().mockReturnValue('test-user'),
53+
qualifiedSegments: null as string[] | null,
5354
} as unknown as OptimizelyUserContext;
5455

5556
const onReadyDeferred = createDeferred();
@@ -338,6 +339,181 @@ describe('UserContextManager', () => {
338339
});
339340
});
340341

342+
// ============================================================
343+
// Scenario 3: Pre-set Qualified Segments
344+
// ============================================================
345+
describe('pre-set qualified segments', () => {
346+
describe('qualifiedSegments + skipSegments=true', () => {
347+
it('should set ctx.qualifiedSegments, fire onUserContextReady once, no background fetch', async () => {
348+
const { client, mockUserContext } = createMockClient({
349+
hasOdpManager: true,
350+
hasVuidManager: false,
351+
});
352+
const config = createManagerConfig(client, { skipSegments: true });
353+
const manager = new UserContextManager(config);
354+
355+
manager.createUserContext({ id: 'user-1' }, ['seg-a', 'seg-b']);
356+
357+
expect(client.createUserContext).toHaveBeenCalledWith('user-1', undefined);
358+
expect(mockUserContext.qualifiedSegments).toEqual(['seg-a', 'seg-b']);
359+
expect(config.onUserContextReady).toHaveBeenCalledTimes(1);
360+
expect(config.onUserContextReady).toHaveBeenCalledWith(mockUserContext);
361+
expect(mockUserContext.fetchQualifiedSegments).not.toHaveBeenCalled();
362+
expect(client.onReady).not.toHaveBeenCalled();
363+
364+
manager.dispose();
365+
});
366+
});
367+
368+
describe('qualifiedSegments + skipSegments=false + ODP + segments match', () => {
369+
it('should callback with pre-set segments immediately than skip second callback when background fetch returns matching segments', async () => {
370+
const { client, mockUserContext, onReadyDeferred } = createMockClient({
371+
hasOdpManager: true,
372+
hasVuidManager: false,
373+
isOdpIntegrated: true,
374+
});
375+
// fetchQualifiedSegments will keep the same segments (simulate match)
376+
(mockUserContext.fetchQualifiedSegments as ReturnType<typeof vi.fn>).mockImplementation(async () => {
377+
mockUserContext.qualifiedSegments = ['seg-a', 'seg-b'];
378+
return true;
379+
});
380+
const config = createManagerConfig(client, { skipSegments: false });
381+
const manager = new UserContextManager(config);
382+
383+
manager.createUserContext({ id: 'user-1' }, ['seg-a', 'seg-b']);
384+
385+
// Immediate callback with pre-set segments
386+
expect(mockUserContext.qualifiedSegments).toEqual(['seg-a', 'seg-b']);
387+
expect(config.onUserContextReady).toHaveBeenCalledTimes(1);
388+
389+
// Background fetch waiting on onReady
390+
expect(client.onReady).toHaveBeenCalled();
391+
392+
onReadyDeferred.resolve(undefined);
393+
await flushPromises();
394+
395+
// Background fetch returned matching segments — no second callback
396+
expect(mockUserContext.fetchQualifiedSegments).toHaveBeenCalled();
397+
expect(config.onUserContextReady).toHaveBeenCalledTimes(1);
398+
399+
manager.dispose();
400+
});
401+
});
402+
403+
describe('qualifiedSegments + skipSegments=false + ODP + segments differ', () => {
404+
it('should callback with pre-set segments immediately then callback again when background fetch returns different segments', async () => {
405+
const { client, mockUserContext, onReadyDeferred } = createMockClient({
406+
hasOdpManager: true,
407+
hasVuidManager: false,
408+
isOdpIntegrated: true,
409+
});
410+
// fetchQualifiedSegments returns different segments
411+
(mockUserContext.fetchQualifiedSegments as ReturnType<typeof vi.fn>).mockImplementation(async () => {
412+
mockUserContext.qualifiedSegments = ['seg-a', 'seg-c'];
413+
return true;
414+
});
415+
const config = createManagerConfig(client, { skipSegments: false });
416+
const manager = new UserContextManager(config);
417+
418+
manager.createUserContext({ id: 'user-1' }, ['seg-a', 'seg-b']);
419+
420+
// Immediate callback with pre-set segments
421+
expect(config.onUserContextReady).toHaveBeenCalledTimes(1);
422+
423+
onReadyDeferred.resolve(undefined);
424+
await flushPromises();
425+
426+
// Background fetch returned different segments — second callback fires
427+
expect(mockUserContext.fetchQualifiedSegments).toHaveBeenCalled();
428+
expect(config.onUserContextReady).toHaveBeenCalledTimes(2);
429+
430+
manager.dispose();
431+
});
432+
});
433+
434+
describe('qualifiedSegments + skipSegments=false + ODP not integrated', () => {
435+
it('should callback with pre-set segments immediately and skip background fetch when ODP is not integrated', async () => {
436+
const { client, mockUserContext, onReadyDeferred } = createMockClient({
437+
hasOdpManager: true,
438+
hasVuidManager: false,
439+
isOdpIntegrated: false,
440+
});
441+
const config = createManagerConfig(client, { skipSegments: false });
442+
const manager = new UserContextManager(config);
443+
444+
manager.createUserContext({ id: 'user-1' }, ['seg-a']);
445+
446+
// Immediate callback with pre-set segments
447+
expect(mockUserContext.qualifiedSegments).toEqual(['seg-a']);
448+
expect(config.onUserContextReady).toHaveBeenCalledTimes(1);
449+
450+
onReadyDeferred.resolve(undefined);
451+
await flushPromises();
452+
453+
// ODP not integrated — no background fetch, no second callback
454+
expect(client.isOdpIntegrated).toHaveBeenCalled();
455+
expect(mockUserContext.fetchQualifiedSegments).not.toHaveBeenCalled();
456+
expect(config.onUserContextReady).toHaveBeenCalledTimes(1);
457+
458+
manager.dispose();
459+
});
460+
});
461+
462+
describe('qualifiedSegments + skipSegments=false + no ODP manager', () => {
463+
it('should callback with pre-set segments immediately and skip background fetch without ODP manager', async () => {
464+
const { client, mockUserContext } = createMockClient({
465+
hasOdpManager: false,
466+
hasVuidManager: false,
467+
});
468+
const config = createManagerConfig(client, { skipSegments: false });
469+
const manager = new UserContextManager(config);
470+
471+
manager.createUserContext({ id: 'user-1' }, ['seg-a', 'seg-b']);
472+
await flushPromises();
473+
474+
// Immediate callback with pre-set segments only — no ODP manager, no background fetch
475+
expect(mockUserContext.qualifiedSegments).toEqual(['seg-a', 'seg-b']);
476+
expect(config.onUserContextReady).toHaveBeenCalledTimes(1);
477+
expect(client.onReady).not.toHaveBeenCalled();
478+
expect(mockUserContext.fetchQualifiedSegments).not.toHaveBeenCalled();
479+
480+
manager.dispose();
481+
});
482+
});
483+
484+
describe('qualifiedSegments=[] empty array', () => {
485+
it('should treat empty array as explicit zero segments, callback immediately, then callback again after background fetch', async () => {
486+
const { client, mockUserContext, onReadyDeferred } = createMockClient({
487+
hasOdpManager: true,
488+
hasVuidManager: false,
489+
isOdpIntegrated: true,
490+
});
491+
// fetchQualifiedSegments returns segments (differ from empty)
492+
(mockUserContext.fetchQualifiedSegments as ReturnType<typeof vi.fn>).mockImplementation(async () => {
493+
mockUserContext.qualifiedSegments = ['seg-x'];
494+
return true;
495+
});
496+
const config = createManagerConfig(client, { skipSegments: false });
497+
const manager = new UserContextManager(config);
498+
499+
manager.createUserContext({ id: 'user-1' }, []); // empty array is truthy in JS
500+
501+
// Immediate callback with empty segments
502+
expect(mockUserContext.qualifiedSegments).toEqual([]);
503+
expect(config.onUserContextReady).toHaveBeenCalledTimes(1);
504+
505+
onReadyDeferred.resolve(undefined);
506+
await flushPromises();
507+
508+
// Background fetch returned different segments — second callback fires
509+
expect(mockUserContext.fetchQualifiedSegments).toHaveBeenCalled();
510+
expect(config.onUserContextReady).toHaveBeenCalledTimes(2);
511+
512+
manager.dispose();
513+
});
514+
});
515+
});
516+
341517
// ============================================================
342518
// Race conditions
343519
// ============================================================
@@ -411,6 +587,54 @@ describe('UserContextManager', () => {
411587

412588
manager.dispose();
413589
});
590+
591+
it('should suppress background fetch callback of stale request when user changes after pre-set segments callback', async () => {
592+
const segmentDeferred = createDeferred<boolean>();
593+
const { client, mockUserContext, onReadyDeferred } = createMockClient({
594+
hasOdpManager: true,
595+
hasVuidManager: false,
596+
isOdpIntegrated: true,
597+
});
598+
(mockUserContext.fetchQualifiedSegments as ReturnType<typeof vi.fn>).mockReturnValue(segmentDeferred.promise);
599+
600+
const config = createManagerConfig(client, { skipSegments: false });
601+
const manager = new UserContextManager(config);
602+
603+
// First call with qualifiedSegments — pre-set segments callback fires immediately
604+
manager.createUserContext({ id: 'user-1' }, ['seg-a']);
605+
606+
// Pre-set segments callback of first request fired
607+
expect(config.onUserContextReady).toHaveBeenCalledTimes(1);
608+
609+
// Resolve onReady so background fetch starts
610+
onReadyDeferred.resolve(undefined);
611+
await flushPromises();
612+
613+
expect(mockUserContext.fetchQualifiedSegments).toHaveBeenCalled();
614+
615+
// New user call invalidates the first request
616+
const newCtx = {
617+
getUserId: vi.fn().mockReturnValue('user-2'),
618+
qualifiedSegments: null as string[] | null,
619+
fetchQualifiedSegments: vi.fn().mockResolvedValue(true),
620+
} as unknown as OptimizelyUserContext;
621+
(client.createUserContext as ReturnType<typeof vi.fn>).mockReturnValue(newCtx);
622+
manager.createUserContext({ id: 'user-2' });
623+
await flushPromises();
624+
625+
expect(config.onUserContextReady).toHaveBeenCalledTimes(2);
626+
627+
// First request's background fetch completes — callback should be suppressed (stale)
628+
(mockUserContext as unknown as { qualifiedSegments: string[] }).qualifiedSegments = ['seg-a', 'seg-new'];
629+
segmentDeferred.resolve(true);
630+
631+
await flushPromises();
632+
633+
// Still only 2 calls — background fetch callback of stale request was suppressed
634+
expect(config.onUserContextReady).toHaveBeenCalledTimes(2);
635+
636+
manager.dispose();
637+
});
414638
});
415639

416640
// ============================================================

src/utils/UserContextManager.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,14 @@ export class UserContextManager {
5555
/**
5656
* Creates a user context, optionally waiting for VUID resolution and
5757
* fetching ODP segments. Only the latest call's callbacks will fire.
58+
*
59+
* @param user - Optional user info (id and attributes)
60+
* @param qualifiedSegments - Optional pre-fetched segments. When provided,
5861
*/
59-
createUserContext(user?: UserInfo): void {
62+
createUserContext(user?: UserInfo, qualifiedSegments?: string[]): void {
6063
const requestId = ++this.requestId;
6164

62-
this.resolveUserContext(requestId, user).catch((error: unknown) => {
65+
this.resolveUserContext(requestId, user, qualifiedSegments).catch((error: unknown) => {
6366
if (this.isStale(requestId)) return;
6467
this.onError(error instanceof Error ? error : new Error(String(error)));
6568
});
@@ -72,14 +75,44 @@ export class UserContextManager {
7275
this.disposed = true;
7376
}
7477

75-
private async resolveUserContext(requestId: number, user?: UserInfo): Promise<void> {
78+
private async resolveUserContext(requestId: number, user?: UserInfo, qualifiedSegments?: string[]): Promise<void> {
7679
if (!user?.id && this.meta.hasVuidManager) {
7780
await this.client.onReady();
7881
if (this.isStale(requestId)) return;
7982
}
8083

8184
const ctx = this.client.createUserContext(user?.id, user?.attributes);
8285

86+
if (qualifiedSegments) {
87+
ctx.qualifiedSegments = qualifiedSegments;
88+
89+
this.onUserContextReady(ctx); // immediate callback for sync decision with pre-set segments
90+
91+
if (this.skipSegments) return;
92+
93+
// Background fetch — only when ODP manager exists
94+
if (this.meta.hasOdpManager) {
95+
await this.client.onReady();
96+
97+
if (this.isStale(requestId)) return;
98+
99+
if (this.client.isOdpIntegrated()) {
100+
const snapshot = [...qualifiedSegments];
101+
102+
await ctx.fetchQualifiedSegments();
103+
104+
if (this.isStale(requestId)) return;
105+
106+
// update only if different
107+
if (!this.segmentsEqual(snapshot, ctx.qualifiedSegments)) {
108+
this.onUserContextReady(ctx);
109+
}
110+
}
111+
}
112+
return;
113+
}
114+
115+
// Step 3: Original path (no qualifiedSegments)
83116
if (!this.skipSegments && this.meta.hasOdpManager) {
84117
await this.client.onReady();
85118
if (this.isStale(requestId)) return;
@@ -93,6 +126,15 @@ export class UserContextManager {
93126
this.onUserContextReady(ctx);
94127
}
95128

129+
private segmentsEqual(a: string[] | null, b: string[] | null): boolean {
130+
if (a === b) return true;
131+
if (!a || !b) return false;
132+
if (a.length !== b.length) return false;
133+
const sortedA = [...a].sort();
134+
const sortedB = [...b].sort();
135+
return sortedA.every((val, i) => val === sortedB[i]);
136+
}
137+
96138
private isStale(requestId: number): boolean {
97139
return this.disposed || requestId !== this.requestId;
98140
}

0 commit comments

Comments
 (0)