salsify-experiences-sdk
Version:
SDK to be used by commerce websites to implement product experiences.
380 lines (330 loc) • 16.2 kB
text/typescript
/**
* @jest-environment jsdom
*/
import EnhancedContentApi from '../index'
import { PerProductConfigCache, selectSource } from '../perProductConfig'
import { makeContext, makeSettings, makeResponse } from '../../__tests__/helpers'
import commonSuite from './index.common'
import request from '../../utils/request'
jest.mock('../../utils/request')
jest.mock('../perProductConfig')
const clientId = 'client-id'
const languageCode = 'lang-code'
const enhancedContent = {}
const productWithCdnRoot =
'https://testenv.test.salsify.com/brand-space-cdn/sdk/client-id/lang-code/BTF/id-type/existing-product/index.html'
const productWithEcPath = `https://salsify-ecdn.com/sdk/${clientId}/${languageCode}/BTF/id-type/existing-product`
let ecApi: EnhancedContentApi
const log = jest.fn()
const settings = makeSettings({ clientId, languageCode, enhancedContent, tracking: true })
const settingsNoTrack = makeSettings({ clientId, languageCode, enhancedContent, tracking: false })
const settingsCdnRoot = makeSettings({
clientId,
languageCode,
enhancedContent,
tracking: true,
cdnRoot: 'https://testenv.test.salsify.com/brand-space-cdn',
})
commonSuite('browser')
const PerProductConfigCacheMock = PerProductConfigCache as jest.Mock
const selectSourceMock = selectSource as jest.Mock
const headMock = request.head as jest.Mock
const exampleContent = '<div>enhanced-content</div>'
const emptyContent = ''
describe('EnhancedContentApi (browser)', () => {
beforeEach(() => {
ecApi = new EnhancedContentApi(settings, makeContext(), { log })
PerProductConfigCacheMock.mock.instances[0].getConfig.mockImplementation(() => undefined)
})
afterEach(() => {
jest.clearAllMocks()
})
describe('renderIframe', () => {
beforeEach(() => {
headMock.mockImplementation(() => makeResponse(exampleContent))
})
// This must all be one test because the iframeResizeListener can only be attached once
test('iframe resize listener', async () => {
let eventListenerCallback!: EventListener
window.addEventListener = jest.fn((_type, callback) => (eventListenerCallback = callback as EventListener))
const container = document.createElement('div')
await ecApi.renderIframe(container, 'foo', 'id-type')
await ecApi.renderIframe(container, 'foo', 'id-type')
// iframe resize listener is attached once and only once
expect(window.addEventListener).toHaveBeenCalledTimes(1)
eventListenerCallback(
new MessageEvent('message', {
origin: 'https://salsify-ecdn.com',
data: { messageType: 'heightUpdateRequest', height: 100 },
})
)
// iframe is not found because container hasn't been appended to document yet
expect(log).toBeCalledTimes(3) // two ec_render_iframe + one error
expect(log).toHaveBeenNthCalledWith(3, 'error', {
errorContext: 'iframeResizeListener',
errorType: 'dom',
errorMessage: 'Could not find iframe with selector #salsify-ec-iframe',
})
document.querySelector('body')?.append(container)
const iframe = document.querySelector('#salsify-ec-iframe') as HTMLIFrameElement
expect(iframe.height).toBe('0')
eventListenerCallback(
new MessageEvent('message', {
origin: 'https://salsify-ecdn.com',
data: { messageType: 'heightUpdateRequest', height: 100 },
})
)
// iframe is found after appending container, so no additional error is logged
expect(log).toBeCalledTimes(3)
expect(iframe.height).toBe('100')
})
test('should throw error when no product ID type is specified when calling', async () => {
const container = document.createElement('div')
const check = (): Promise<void> | undefined => ecApi.renderIframe(container, 'product')
return expect(check).rejects.toThrowError('No ID type specified.')
})
test('it renders the iframe with the correct src', async () => {
const container = document.createElement('div')
await ecApi.renderIframe(container, 'existing-product', 'id-type')
expect(container.firstElementChild?.getAttribute('src')).toBe(`${productWithEcPath}/index.html`)
expect(PerProductConfigCacheMock.mock.instances.length).toBe(1)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledTimes(1)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledWith(productWithEcPath)
})
test('it renders the iframe with the cdn root url', async () => {
ecApi = new EnhancedContentApi(settingsCdnRoot, makeContext(), { log })
const container = document.createElement('div')
await ecApi.renderIframe(container, 'existing-product', 'id-type')
expect(container.firstElementChild?.getAttribute('src')).toBe(productWithCdnRoot)
})
test('it sends an ec_render_iframe event', async () => {
PerProductConfigCacheMock.mock.instances[0].getConfig.mockResolvedValue(undefined)
const container = document.createElement('div')
await ecApi.renderIframe(container, 'existing-product', 'id-type')
expect(headMock).toHaveBeenCalledTimes(1)
expect(headMock).toHaveBeenCalledWith(`${productWithEcPath}/index.html`)
const renderConfig = {
idType: 'id-type',
productId: 'existing-product',
content: null,
allContentExists: false,
source: 'index.html',
sourceExists: true,
}
expect(ecApi.lastRenderConfig).toMatchObject(renderConfig)
expect(log).toHaveBeenCalledWith('ec_render_iframe', renderConfig)
})
describe('when EC is missing', () => {
beforeEach(() => {
headMock.mockImplementation(() => makeResponse(emptyContent))
})
test('it sends an ec_render_iframe event with sourceExists: false', async () => {
PerProductConfigCacheMock.mock.instances[0].getConfig.mockResolvedValue(undefined)
const container = document.createElement('div')
await ecApi.renderIframe(container, 'existing-product', 'id-type')
expect(headMock).toHaveBeenCalledTimes(1)
expect(headMock).toHaveBeenCalledWith(`${productWithEcPath}/index.html`)
const renderConfig = {
idType: 'id-type',
productId: 'existing-product',
content: null,
allContentExists: false,
source: 'index.html',
sourceExists: false,
}
expect(ecApi.lastRenderConfig).toMatchObject(renderConfig)
expect(log).toHaveBeenCalledWith('ec_render_iframe', renderConfig)
})
})
describe('with config content', () => {
const content = [
{ source: 'foo.html', weight: 0.4 },
{ source: 'bar.html', weight: 0.4 },
{ source: null, weight: 0.2 },
]
beforeEach(() => {
PerProductConfigCacheMock.mock.instances[0].getConfig.mockImplementation(() => ({
content,
}))
})
test('it renders the iframe with the selected src', async () => {
selectSourceMock.mockImplementation(() => content[1])
const container = document.createElement('div')
await ecApi.renderIframe(container, 'existing-product', 'id-type')
expect(container.firstElementChild?.getAttribute('src')).toBe(`${productWithEcPath}/bar.html`)
expect(PerProductConfigCacheMock.mock.instances.length).toBe(1)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledTimes(1)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledWith(productWithEcPath)
expect(headMock).toHaveBeenCalledTimes(2)
expect(headMock).toHaveBeenCalledWith(`${productWithEcPath}/foo.html`)
expect(headMock).toHaveBeenCalledWith(`${productWithEcPath}/bar.html`)
const renderConfig = {
idType: 'id-type',
productId: 'existing-product',
content,
allContentExists: true,
source: 'bar.html',
sourceExists: true,
}
expect(ecApi.lastRenderConfig).toMatchObject(renderConfig)
expect(log).toHaveBeenCalledWith('ec_render_iframe', renderConfig)
})
test('it does not render an iframe when a null source is selected', async () => {
selectSourceMock.mockImplementation(() => content[2])
const container = document.createElement('div')
await ecApi.renderIframe(container, 'existing-product', 'id-type')
expect(container.firstElementChild).toBeNull()
expect(PerProductConfigCacheMock.mock.instances.length).toBe(1)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledTimes(1)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledWith(productWithEcPath)
expect(headMock).toHaveBeenCalledTimes(2)
expect(headMock).toHaveBeenCalledWith(`${productWithEcPath}/foo.html`)
expect(headMock).toHaveBeenCalledWith(`${productWithEcPath}/bar.html`)
const renderConfig = {
idType: 'id-type',
productId: 'existing-product',
content,
allContentExists: true,
source: null,
sourceExists: false,
}
expect(ecApi.lastRenderConfig).toMatchObject(renderConfig)
expect(log).toHaveBeenCalledWith('ec_render_iframe', renderConfig)
})
describe('when tracking is disabled', () => {
beforeEach(() => {
ecApi = new EnhancedContentApi(settingsNoTrack, makeContext(), { log })
PerProductConfigCacheMock.mock.instances[1].getConfig.mockImplementation(() => ({
content,
}))
})
test('it falls back to default source', async () => {
selectSourceMock.mockImplementation(() => content[1])
const container = document.createElement('div')
await ecApi.renderIframe(container, 'existing-product', 'id-type')
expect(container.firstElementChild?.getAttribute('src')).toBe(`${productWithEcPath}/index.html`)
expect(PerProductConfigCacheMock.mock.instances.length).toBe(2)
expect(PerProductConfigCacheMock.mock.instances[1].getConfig).toHaveBeenCalledTimes(1)
expect(PerProductConfigCacheMock.mock.instances[1].getConfig).toHaveBeenCalledWith(productWithEcPath)
expect(headMock).toHaveBeenCalledTimes(3)
expect(headMock).toHaveBeenCalledWith(`${productWithEcPath}/foo.html`)
expect(headMock).toHaveBeenCalledWith(`${productWithEcPath}/bar.html`)
expect(headMock).toHaveBeenCalledWith(`${productWithEcPath}/index.html`)
const renderConfig = {
idType: 'id-type',
productId: 'existing-product',
content,
allContentExists: true,
source: 'index.html',
sourceExists: true,
}
expect(ecApi.lastRenderConfig).toMatchObject(renderConfig)
expect(log).toHaveBeenCalledWith('ec_render_iframe', renderConfig)
})
})
describe('when all content is missing', () => {
beforeEach(() => {
headMock.mockImplementation(() => makeResponse(emptyContent))
})
test('it reports allContentExists: false, sourceExists: false', async () => {
selectSourceMock.mockImplementation(() => content[1])
const container = document.createElement('div')
await ecApi.renderIframe(container, 'existing-product', 'id-type')
expect(container.firstElementChild?.getAttribute('src')).toBe(`${productWithEcPath}/bar.html`)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledTimes(1)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledWith(productWithEcPath)
expect(headMock).toHaveBeenCalledWith(`${productWithEcPath}/foo.html`)
expect(headMock).toHaveBeenCalledWith(`${productWithEcPath}/bar.html`)
expect(headMock).toHaveBeenCalledTimes(2)
const renderConfig = {
idType: 'id-type',
productId: 'existing-product',
content,
allContentExists: false,
source: 'bar.html',
sourceExists: false,
}
expect(ecApi.lastRenderConfig).toMatchObject(renderConfig)
expect(log).toHaveBeenCalledWith('ec_render_iframe', renderConfig)
})
})
describe('when some content is missing, but selected source is not', () => {
beforeEach(() => {
headMock.mockImplementation((url: string) =>
makeResponse(url.match(/\/bar\.html$/) ? emptyContent : exampleContent)
)
})
test('it reports allContentExists: false, sourceExists: true', async () => {
selectSourceMock.mockImplementation(() => content[0])
const container = document.createElement('div')
await ecApi.renderIframe(container, 'existing-product', 'id-type')
expect(container.firstElementChild?.getAttribute('src')).toBe(`${productWithEcPath}/foo.html`)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledTimes(1)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledWith(productWithEcPath)
expect(headMock).toHaveBeenCalledTimes(2)
const renderConfig = {
idType: 'id-type',
productId: 'existing-product',
content,
allContentExists: false,
source: 'foo.html',
sourceExists: true,
}
expect(ecApi.lastRenderConfig).toMatchObject(renderConfig)
expect(log).toHaveBeenCalledWith('ec_render_iframe', renderConfig)
})
})
})
describe('with only null config content', () => {
const content = [{ source: null, weight: 1 }]
beforeEach(() => {
PerProductConfigCacheMock.mock.instances[0].getConfig.mockImplementation(() => ({
content,
}))
})
test('it does not render an iframe and reports allContentExists: false, sourceExists: false', async () => {
selectSourceMock.mockImplementation(() => content[0])
const container = document.createElement('div')
await ecApi.renderIframe(container, 'existing-product', 'id-type')
expect(container.firstElementChild).toBeNull()
expect(PerProductConfigCacheMock.mock.instances.length).toBe(1)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledTimes(1)
expect(PerProductConfigCacheMock.mock.instances[0].getConfig).toHaveBeenCalledWith(productWithEcPath)
expect(headMock).toHaveBeenCalledTimes(0)
const renderConfig = {
idType: 'id-type',
productId: 'existing-product',
content,
allContentExists: false,
source: null,
sourceExists: false,
}
expect(ecApi.lastRenderConfig).toMatchObject(renderConfig)
expect(log).toHaveBeenCalledWith('ec_render_iframe', renderConfig)
})
})
describe('beforeRender', () => {
let beforeRender: () => void
let ecApi: EnhancedContentApi
beforeEach(() => {
beforeRender = jest.fn()
ecApi = new EnhancedContentApi(settings, makeContext(), { log: jest.fn() }, { beforeRender })
})
test('calls beforeRender when set', async () => {
const container = document.createElement('div')
await ecApi.renderIframe(container, 'product', 'foo')
expect(beforeRender).toHaveBeenCalledTimes(1)
})
})
})
describe('updateLanguageCode', () => {
test('it updates the language code used for subsequent requests', async () => {
const newLanguageCode = 'new-lang-code'
ecApi.updateLanguageCode(newLanguageCode)
const container = document.createElement('div')
await ecApi.renderIframe(container, 'existing-product', 'id-type')
const productWithNewLanguageCode = `${productWithEcPath}/index.html`.replace('lang-code', newLanguageCode)
expect(container.firstElementChild?.getAttribute('src')).toBe(productWithNewLanguageCode)
})
})
})