UNPKG

salsify-experiences-sdk

Version:

SDK to be used by commerce websites to implement product experiences.

713 lines (657 loc) 25.1 kB
import { isPerProductConfig, selectSource, PerProductConfigCache } from '../perProductConfig' import request from '../../utils/request' import { createLogger } from '../../utils/logger' import { makeResponse, makeContext, makeSettings } from '../../__tests__/helpers' import { webcrypto } from 'crypto' jest.mock('../../utils/request') jest.mock('../../utils/logger') const getMock = request.get as jest.Mock const logMock = jest.fn() ;(createLogger as jest.Mock).mockReturnValue({ log: logMock }) const productCdnPath = 'https://salsify-ecdn.com/sdk/client-id/lang-code/BTF/id-type/existing-product' const productWithConfigUrl = `${productCdnPath}/config.json` const exampleConfigJson = ` { "content": [ { "source": "index.html", "weight": 0.95 }, { "source": null, "weight": 0.05 } ] } ` const exampleConfig = JSON.parse(exampleConfigJson) const invalidConfigJson = ` { "content": [ {} ] } ` const nonJson = '<h1>Hello There!</h1>' describe('PerProductConfig (server)', () => { describe('isPerProductConfig', () => { test('empty config is invalid', () => { expect( isPerProductConfig( JSON.parse(` {} `) ) ).toBeFalsy() }) describe('experiments', () => { test('empty content list is invalid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [] } `) ) ).toBeFalsy() }) describe('Source', () => { test('empty source object is invalid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ {} ] } `) ) ).toBeFalsy() }) test('missing weight key is invalid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "source": "index.html" } ] } `) ) ).toBeFalsy() }) test('missing source key is invalid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "weight": 1 } ] } `) ) ).toBeFalsy() }) test('number source is invalid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "source": 100, "weight": 1 } ] } `) ) ).toBeFalsy() }) test('string weight is invalid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "source": "index.html", "weight": "1" } ] } `) ) ).toBeFalsy() }) test('weights that sum > 1 are invalid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "source": "index.html", "weight": 0.95 }, { "source": null, "weight": 0.15 } ] } `) ) ).toBeFalsy() }) test('weights that sum < 1 are invalid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "source": "index.html", "weight": 0.90 }, { "source": null, "weight": 0.05 } ] } `) ) ).toBeFalsy() }) test('weights with > two digits after the decimal that sum to 1 are valid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "source": "a.html", "weight": 0.475 }, { "source": "b.html", "weight": 0.475 }, { "source": null, "weight": 0.05 } ] } `) ) ).toBeTruthy() }) test('weights that sum very close to 1 are valid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "source": "a.html", "weight": 0.3333 }, { "source": "b.html", "weight": 0.3333 }, { "source": "c.html", "weight": 0.3333 } ] } `) ) ).toBeTruthy() }) test("weights that don't sum close enough to 1 are invalid", () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "source": "a.html", "weight": 0.33 }, { "source": "b.html", "weight": 0.33 }, { "source": "c.html", "weight": 0.33 } ] } `) ) ).toBeFalsy() }) test('string source and number weight is valid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "source": "index.html", "weight": 1 } ] } `) ) ).toBeTruthy() }) test('null source and number weight is valid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "source": null, "weight": 1 } ] } `) ) ).toBeTruthy() }) test('multiple sources is valid', () => { expect( isPerProductConfig( JSON.parse(` { "content": [ { "source": "index.html", "weight": 0.95 }, { "source": null, "weight": 0.05 } ] } `) ) ).toBeTruthy() }) }) }) }) describe('selectSource', () => { test('it selects a source', () => { const productId = '1234' const content = [ { source: 'a', weight: 0.25 }, { source: 'b', weight: 0.25 }, { source: 'c', weight: 0.25 }, { source: 'd', weight: 0.25 }, ] const sessionId = webcrypto.randomUUID() const source = selectSource({ productId, content, sessionId }) expect(content).toContain(source) }) test('it selects different sources for different sessionIds', () => { const productId = '1234567890' const content = [ { source: 'a', weight: 0.25 }, { source: 'b', weight: 0.25 }, { source: 'c', weight: 0.25 }, { source: 'd', weight: 0.25 }, ] expect(selectSource({ productId, content, sessionId: '9b01d087-b763-4572-8ccd-b602e80cd9d0' })).toBe(content[0]) expect(selectSource({ productId, content, sessionId: '8200229d-ebef-4063-b2e5-2c824c60e528' })).toBe(content[1]) expect(selectSource({ productId, content, sessionId: 'cec4ec2a-0a80-4df1-9519-19f710335b99' })).toBe(content[2]) expect(selectSource({ productId, content, sessionId: '35ea55ca-a19e-4075-9b2b-d9fd6fc7df4d' })).toBe(content[3]) }) test('it selects different sources for different productIds', () => { const content = [ { source: 'a', weight: 0.25 }, { source: 'b', weight: 0.25 }, { source: 'c', weight: 0.25 }, { source: 'd', weight: 0.25 }, ] const sessionId = '9b01d087-b763-4572-8ccd-b602e80cd9d0' expect(selectSource({ productId: '1234567890', content, sessionId })).toBe(content[0]) expect(selectSource({ productId: '1234567899', content, sessionId })).toBe(content[1]) expect(selectSource({ productId: '1234567892', content, sessionId })).toBe(content[2]) expect(selectSource({ productId: '1234567893', content, sessionId })).toBe(content[3]) }) test('it selects different sources for different content sources', () => { const productId = '1234567890' const sessionId = '9b01d087-b763-4572-8ccd-b602e80cd9d0' let content = [ { source: 'a', weight: 0.25 }, { source: 'b', weight: 0.25 }, { source: 'c', weight: 0.25 }, { source: 'd', weight: 0.25 }, ] expect(selectSource({ productId, content, sessionId })).toBe(content[0]) content = [ { source: 'a', weight: 0.25 }, { source: 'e', weight: 0.25 }, { source: 'c', weight: 0.25 }, { source: 'd', weight: 0.25 }, ] expect(selectSource({ productId, content, sessionId })).toBe(content[2]) }) test('it selects different sources for different content weights', () => { const productId = '1234567890' const sessionId = '9b01d087-b763-4572-8ccd-b602e80cd9d0' let content = [ { source: 'a', weight: 0.25 }, { source: 'b', weight: 0.25 }, { source: 'c', weight: 0.25 }, { source: 'd', weight: 0.25 }, ] expect(selectSource({ productId, content, sessionId })).toBe(content[0]) content = [ { source: 'a', weight: 0.3 }, { source: 'b', weight: 0.2 }, { source: 'c', weight: 0.3 }, { source: 'd', weight: 0.2 }, ] expect(selectSource({ productId, content, sessionId })).toBe(content[3]) }) test('it selects sources with uniform distribution across sessionIds', () => { const productId = '1234567890' const content = [ { source: 'a', weight: 0.25 }, { source: 'b', weight: 0.25 }, { source: 'c', weight: 0.25 }, { source: 'd', weight: 0.25 }, ] const counts = { a: 0, b: 0, c: 0, d: 0 } const iterations = 100000 for (let i = 0; i < iterations; i++) { const sessionId = webcrypto.randomUUID() const source = selectSource({ productId, content, sessionId }) counts[source.source as keyof typeof counts]++ } expect(counts.a / iterations).toBeCloseTo(0.25) expect(counts.b / iterations).toBeCloseTo(0.25) expect(counts.c / iterations).toBeCloseTo(0.25) expect(counts.d / iterations).toBeCloseTo(0.25) }) test('it selects null source with low chance at correct rate', () => { const productId = '1234567890' const content = [ { source: 'index.html', weight: 0.95 }, { source: null, weight: 0.05 }, ] let nullSourceCount = 0 const iterations = 100000 for (let i = 0; i < iterations; i++) { const sessionId = webcrypto.randomUUID() const source = selectSource({ productId, content, sessionId }) if (source.source === null) { nullSourceCount++ } } expect(nullSourceCount / iterations).toBeCloseTo(0.05) }) }) describe('PerProductConfigCache', () => { const logger = createLogger(makeContext(), makeSettings()) afterEach(() => { jest.clearAllMocks() }) describe('valid config file', () => { beforeEach(() => { getMock.mockImplementation(() => makeResponse(exampleConfigJson)) }) test('it fetches and returns config on first getConfig', async () => { const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(config).toEqual(exampleConfig) }) test('it returns config from cache on subsequent getConfig', async () => { const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(config).toEqual(exampleConfig) const config2 = await perProductConfigCache.getConfig(productCdnPath) expect(config2).toEqual(exampleConfig) expect(getMock).toBeCalledTimes(1) }) }) describe('error fetching config file', () => { beforeEach(() => { getMock.mockRejectedValue(Error('network error')) }) test('it handles rejection', async () => { const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'fetch', errorMessage: `Error fetching ${productWithConfigUrl}: network error`, }) expect(config).toBeUndefined() }) }) describe('no config file', () => { beforeEach(() => { getMock.mockImplementation(() => makeResponse('')) }) test('it fetches and returns undefined on first getConfig', async () => { const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(config).toBeUndefined() }) test('it returns undefined from cache on subsequent getConfig', async () => { const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(config).toBeUndefined() const config2 = await perProductConfigCache.getConfig(productCdnPath) expect(config2).toBeUndefined() expect(getMock).toBeCalledTimes(1) }) }) describe('invalid config file', () => { beforeEach(() => { getMock.mockImplementation(() => makeResponse(invalidConfigJson)) }) test('it fetches and returns undefined on first getConfig', async () => { const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'validation', errorMessage: `'content' in ${productWithConfigUrl} contains a source that is missing a 'source' key`, }) expect(config).toBeUndefined() }) test('it returns undefined from cache on subsequent getConfig', async () => { const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(config).toBeUndefined() const config2 = await perProductConfigCache.getConfig(productCdnPath) expect(config2).toBeUndefined() expect(getMock).toBeCalledTimes(1) }) describe('specific validation error logging', () => { test('it logs non-object config', async () => { getMock.mockImplementation(() => makeResponse(` 42 `) ) const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'validation', errorMessage: `${productWithConfigUrl} does not contain an object`, }) expect(config).toBeUndefined() }) test('it logs missing content key', async () => { getMock.mockImplementation(() => makeResponse(` {} `) ) const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'validation', errorMessage: `${productWithConfigUrl} does not contain a 'content' key`, }) expect(config).toBeUndefined() }) test('it logs content value not an array', async () => { getMock.mockImplementation(() => makeResponse('{ "content": {} }')) const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'validation', errorMessage: `'content' in ${productWithConfigUrl} is not an array`, }) expect(config).toBeUndefined() }) test('it logs content array is empty', async () => { getMock.mockImplementation(() => makeResponse(` { "content": [] } `) ) const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'validation', errorMessage: `'content' array in ${productWithConfigUrl} has length 0`, }) expect(config).toBeUndefined() }) test('it logs content source that is not an object', async () => { getMock.mockImplementation(() => makeResponse(` { "content": [ { "source": "foo", "weight": 0.5 }, "bar", { "source": "baz", "weight": 0.5 } ] } `) ) const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'validation', errorMessage: `'content' in ${productWithConfigUrl} contains a source that is not an object`, }) expect(config).toBeUndefined() }) test("it logs content source that is missing a 'source' key", async () => { getMock.mockImplementation(() => makeResponse(` { "content": [ { "source": "foo", "weight": 0.333 }, { "weight": 0.333 }, { "source": "baz", "weight": 0.333 } ] } `) ) const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'validation', errorMessage: `'content' in ${productWithConfigUrl} contains a source that is missing a 'source' key`, }) expect(config).toBeUndefined() }) test("it logs content source that has an invalid 'source' value", async () => { getMock.mockImplementation(() => makeResponse(` { "content": [ { "source": "foo", "weight": 0.333 }, { "source": 12345, "weight": 0.333 }, { "source": "baz", "weight": 0.333 } ] } `) ) const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'validation', errorMessage: `'content' in ${productWithConfigUrl} contains a source with an invalid 'source' value`, }) expect(config).toBeUndefined() }) test("it logs content source that is missing a 'weight' key", async () => { getMock.mockImplementation(() => makeResponse(` { "content": [ { "source": "foo", "weight": 0.333 }, { "source": "bar" }, { "source": "baz", "weight": 0.333 } ] } `) ) const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'validation', errorMessage: `'content' in ${productWithConfigUrl} contains a source that is missing a 'weight' key`, }) expect(config).toBeUndefined() }) test("it logs content source that has an invalid 'weight' value", async () => { getMock.mockImplementation(() => makeResponse(` { "content": [ { "source": "foo", "weight": 0.333 }, { "source": "bar", "weight": "0.333" }, { "source": "baz", "weight": 0.333 } ] } `) ) const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'validation', errorMessage: `'content' in ${productWithConfigUrl} contains a source with an invalid 'weight' value`, }) expect(config).toBeUndefined() }) test('it logs content sources whose weights do not sum to 1', async () => { getMock.mockImplementation(() => makeResponse(` { "content": [ { "source": "foo", "weight": 0.3 }, { "source": "bar", "weight": 0.3 }, { "source": "baz", "weight": 0.3 } ] } `) ) const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'validation', errorMessage: `sum of source weights in 'content' in ${productWithConfigUrl} does not equal 1`, }) expect(config).toBeUndefined() }) }) }) describe('non-JSON config file', () => { beforeEach(() => { getMock.mockImplementation(() => makeResponse(nonJson)) }) test('it fetches and returns undefined on first getConfig', async () => { const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(logMock).toBeCalledWith('error', { errorContext: 'per-product config', errorType: 'parse', errorMessage: `Error parsing ${productWithConfigUrl}: Unexpected token < in JSON at position 0`, }) expect(config).toBeUndefined() }) test('it returns undefined from cache on subsequent getConfig', async () => { const perProductConfigCache = new PerProductConfigCache(logger) const config = await perProductConfigCache.getConfig(productCdnPath) expect(getMock).toBeCalledWith(productWithConfigUrl) expect(config).toBeUndefined() const config2 = await perProductConfigCache.getConfig(productCdnPath) expect(config2).toBeUndefined() expect(getMock).toBeCalledTimes(1) expect(logMock).toBeCalledTimes(1) }) }) }) })