diff --git a/packages/node/src/assignment/assignment.ts b/packages/node/src/assignment/assignment.ts index 6bcd162..8944b63 100644 --- a/packages/node/src/assignment/assignment.ts +++ b/packages/node/src/assignment/assignment.ts @@ -1,6 +1,7 @@ import { EvaluationVariant } from '@amplitude/experiment-core'; import { ExperimentUser } from '../types/user'; +import { safeStringTrim } from '../util/string'; /** * @deprecated Assignment tracking is deprecated. Use Exposure tracking. @@ -35,11 +36,14 @@ export class Assignment { } public canonicalize(): string { - let canonical = `${this.user.user_id?.trim()} ${this.user.device_id?.trim()} `; + let canonical = `${safeStringTrim(this.user.user_id)} ${safeStringTrim( + this.user.device_id, + )} `; for (const key of Object.keys(this.results).sort()) { const variant = this.results[key]; if (variant?.key) { - canonical += key.trim() + ' ' + variant?.key?.trim() + ' '; + canonical += + safeStringTrim(key) + ' ' + safeStringTrim(variant.key) + ' '; } } return canonical; diff --git a/packages/node/src/exposure/exposure.ts b/packages/node/src/exposure/exposure.ts index 8293d6a..28b378d 100644 --- a/packages/node/src/exposure/exposure.ts +++ b/packages/node/src/exposure/exposure.ts @@ -1,6 +1,7 @@ import { EvaluationVariant } from '@amplitude/experiment-core'; import { ExperimentUser } from '../types/user'; +import { safeStringTrim } from '../util/string'; export interface ExposureService { track(exposure: Exposure): Promise; @@ -29,11 +30,14 @@ export class Exposure { } public canonicalize(): string { - let canonical = `${this.user.user_id?.trim()} ${this.user.device_id?.trim()} `; + let canonical = `${safeStringTrim(this.user.user_id)} ${safeStringTrim( + this.user.device_id, + )} `; for (const key of Object.keys(this.results).sort()) { const variant = this.results[key]; if (variant?.key) { - canonical += key.trim() + ' ' + variant?.key?.trim() + ' '; + canonical += + safeStringTrim(key) + ' ' + safeStringTrim(variant.key) + ' '; } } return canonical; diff --git a/packages/node/src/util/string.ts b/packages/node/src/util/string.ts new file mode 100644 index 0000000..f7786fa --- /dev/null +++ b/packages/node/src/util/string.ts @@ -0,0 +1,15 @@ +/** + * Safely converts a value to a trimmed string. + * Returns empty string for null/undefined values. + * @param value - The value to convert + * @returns Trimmed string representation + */ +export function safeStringTrim(value: unknown): string { + if (value == null) { + return ''; + } + if (typeof value === 'string') { + return value.trim(); + } + return String(value).trim(); +} diff --git a/packages/node/test/assignment/assignment.test.ts b/packages/node/test/assignment/assignment.test.ts new file mode 100644 index 0000000..9e064d5 --- /dev/null +++ b/packages/node/test/assignment/assignment.test.ts @@ -0,0 +1,32 @@ +import { Assignment } from 'src/assignment/assignment'; +import { ExperimentUser } from 'src/types/user'; + +describe('Assignment.canonicalize()', () => { + test('canonicalizes with string user_id and device_id', () => { + const user: ExperimentUser = { + user_id: 'user123', + device_id: 'device456', + }; + const results = { + 'flag-key-1': { key: 'on', value: 'on' }, + 'flag-key-2': { key: 'control', value: 'control' }, + }; + const assignment = new Assignment(user, results); + expect(assignment.canonicalize()).toBe( + 'user123 device456 flag-key-1 on flag-key-2 control ', + ); + }); + + test('handles non-string user_id and device_id without throwing', () => { + const user: ExperimentUser = { + user_id: 12345 as any, + device_id: 67890 as any, + }; + const results = { + 'flag-key-1': { key: 999 as any, value: 'on' }, + }; + const assignment = new Assignment(user, results); + expect(() => assignment.canonicalize()).not.toThrow(); + expect(assignment.canonicalize()).toBe('12345 67890 flag-key-1 999 '); + }); +}); diff --git a/packages/node/test/exposure/exposure.test.ts b/packages/node/test/exposure/exposure.test.ts new file mode 100644 index 0000000..fe560a4 --- /dev/null +++ b/packages/node/test/exposure/exposure.test.ts @@ -0,0 +1,32 @@ +import { Exposure } from 'src/exposure/exposure'; +import { ExperimentUser } from 'src/types/user'; + +describe('Exposure.canonicalize()', () => { + test('canonicalizes with string user_id and device_id', () => { + const user: ExperimentUser = { + user_id: 'user123', + device_id: 'device456', + }; + const results = { + 'flag-key-1': { key: 'on', value: 'on' }, + 'flag-key-2': { key: 'control', value: 'control' }, + }; + const exposure = new Exposure(user, results); + expect(exposure.canonicalize()).toBe( + 'user123 device456 flag-key-1 on flag-key-2 control ', + ); + }); + + test('handles non-string user_id and device_id without throwing', () => { + const user: ExperimentUser = { + user_id: 12345 as any, + device_id: 67890 as any, + }; + const results = { + 'flag-key-1': { key: 999 as any, value: 'on' }, + }; + const exposure = new Exposure(user, results); + expect(() => exposure.canonicalize()).not.toThrow(); + expect(exposure.canonicalize()).toBe('12345 67890 flag-key-1 999 '); + }); +}); diff --git a/packages/node/test/util/string.test.ts b/packages/node/test/util/string.test.ts new file mode 100644 index 0000000..29c85b1 --- /dev/null +++ b/packages/node/test/util/string.test.ts @@ -0,0 +1,15 @@ +import { safeStringTrim } from 'src/util/string'; + +describe('safeStringTrim', () => { + test('trims string values', () => { + expect(safeStringTrim(' hello ')).toBe('hello'); + expect(safeStringTrim('test')).toBe('test'); + }); + + test('converts non-string values to trimmed strings', () => { + expect(safeStringTrim(123)).toBe('123'); + expect(safeStringTrim(null)).toBe(''); + expect(safeStringTrim(undefined)).toBe(''); + expect(safeStringTrim(true)).toBe('true'); + }); +});