salsify-experiences-sdk
Version:
SDK to be used by commerce websites to implement product experiences.
713 lines (657 loc) • 25.1 kB
text/typescript
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)
})
})
})
})