salsify-experiences-sdk
Version:
SDK to be used by commerce websites to implement product experiences.
196 lines (164 loc) • 7.66 kB
text/typescript
import request from '../../request'
import Transport from '../log-service-transport'
import { makeContext, makeResponse, makeSettings } from '../../../__tests__/helpers'
import { webcrypto } from 'crypto'
import HttpStatus from 'http-status-codes'
jest.mock('../../request')
const flushPromises = (): Promise<void> => new Promise(process.nextTick)
describe('log-service-transport', () => {
beforeEach(() => {
jest.useFakeTimers({ doNotFake: ['nextTick'] })
jest.spyOn(request, 'post').mockImplementation(async () => makeResponse('', HttpStatus.OK))
})
afterEach(() => {
jest.resetAllMocks()
jest.useRealTimers()
})
test('should transform to log-service data model before sending', async () => {
const context = makeContext()
const settings = makeSettings()
const transport = new Transport(context, settings)
transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
await flushPromises()
expect(request.post).toHaveBeenCalledTimes(1)
const call = (request.post as jest.Mock).mock.calls[0]
expect(call[0]).toBe('https://retail-client-events-service.internal.salsify.com/events')
expect(call[1].app).toBe('sxp_sdk')
expect(call[1].channel).toBe(context.clientId)
expect(call[1].csid).toBe(context.sessionId)
expect(call[1].pagesessionid).toBe(context.pageSessionId)
expect(call[1].jsSource).toBe(context.jsSource)
expect(typeof call[1].timestamp).toBe('number')
expect(call[1].logs).toHaveLength(1)
expect(call[1].logs[0].code).toBe('sdk_foo')
expect(call[1].logs[0].properties.bar).toBe('zaz')
expect(call[1].logs[0].context.url).toBe('example.com')
expect(typeof call[1].logs[0].timestamp).toBe('number')
})
test('should use new pageSessionId when updated', async () => {
const context = makeContext()
const settings = makeSettings()
const transport = new Transport(context, settings)
transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
await flushPromises()
expect(request.post).toHaveBeenCalledTimes(1)
const call = (request.post as jest.Mock).mock.calls[0]
expect(call[1].pagesessionid).toBe(context.pageSessionId)
const newPageSessionId = webcrypto.randomUUID()
context.pageSessionId = newPageSessionId
transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
await flushPromises()
expect(request.post).toHaveBeenCalledTimes(2)
const call2 = (request.post as jest.Mock).mock.calls[1]
expect(call2[1].pagesessionid).toBe(newPageSessionId)
})
test('should send to staging when configured', async () => {
const context = makeContext()
const settings = makeSettings({ staging: true })
const transport = new Transport(context, settings)
transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
await flushPromises()
expect(request.post).toHaveBeenCalledTimes(1)
const call = (request.post as jest.Mock).mock.calls[0]
expect(call[0]).toBe('https://retail-client-events-service-staging.internal.salsify.com/events')
})
test('it should resend failed events on network error', async () => {
const context = makeContext()
const settings = makeSettings()
const transport = new Transport(context, settings)
const postSpy = jest.spyOn(request, 'post').mockRejectedValue(new Error())
transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
expect(request.post).toHaveBeenCalledTimes(1)
expect(postSpy.mock.calls[0][1].logs).toEqual([expect.objectContaining({ code: 'sdk_foo' })])
await flushPromises()
transport.log('foo2', { bar: 'zaz2' }, { url: 'example2.com' })
expect(request.post).toHaveBeenCalledTimes(2)
await flushPromises()
jest.advanceTimersByTime(5000)
// All failed events are included in retry
expect(request.post).toHaveBeenCalledTimes(3)
expect(postSpy.mock.calls[2][1].logs).toEqual([
expect.objectContaining({ code: 'sdk_foo' }),
expect.objectContaining({ code: 'sdk_foo2' }),
])
await flushPromises()
jest.advanceTimersByTime(20000)
// Retries happen with exponential backoff
expect(request.post).toHaveBeenCalledTimes(4)
expect(postSpy.mock.calls[3][1].logs).toEqual([
expect.objectContaining({ code: 'sdk_foo' }),
expect.objectContaining({ code: 'sdk_foo2' }),
])
await flushPromises()
})
test('it should resend failed events on HTTP error', async () => {
const context = makeContext()
const settings = makeSettings()
const transport = new Transport(context, settings)
const postSpy = jest
.spyOn(request, 'post')
.mockImplementation(async () => makeResponse('', HttpStatus.SERVICE_UNAVAILABLE))
transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
expect(request.post).toHaveBeenCalledTimes(1)
expect(postSpy.mock.calls[0][1].logs).toEqual([expect.objectContaining({ code: 'sdk_foo' })])
await flushPromises()
transport.log('foo2', { bar: 'zaz2' }, { url: 'example2.com' })
expect(request.post).toHaveBeenCalledTimes(2)
await flushPromises()
jest.advanceTimersByTime(5000)
// All failed events are included in retry
expect(request.post).toHaveBeenCalledTimes(3)
expect(postSpy.mock.calls[2][1].logs).toEqual([
expect.objectContaining({ code: 'sdk_foo' }),
expect.objectContaining({ code: 'sdk_foo2' }),
])
await flushPromises()
jest.advanceTimersByTime(20000)
// Retries happen with exponential backoff
expect(request.post).toHaveBeenCalledTimes(4)
expect(postSpy.mock.calls[3][1].logs).toEqual([
expect.objectContaining({ code: 'sdk_foo' }),
expect.objectContaining({ code: 'sdk_foo2' }),
])
await flushPromises()
})
test('it should clear successfully resent events from the queue and reset the timer', async () => {
const context = makeContext()
const settings = makeSettings()
const transport = new Transport(context, settings)
jest.spyOn(request, 'post').mockRejectedValueOnce(new Error())
transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
await flushPromises()
expect(request.post).toHaveBeenCalledTimes(1)
jest.advanceTimersByTime(5000)
await flushPromises()
expect(request.post).toHaveBeenCalledTimes(2)
jest.advanceTimersByTime(20000)
await flushPromises()
expect(request.post).toHaveBeenCalledTimes(2)
jest.spyOn(request, 'post').mockImplementationOnce(async () => makeResponse('', HttpStatus.SERVICE_UNAVAILABLE))
transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
await flushPromises()
expect(request.post).toHaveBeenCalledTimes(3)
jest.advanceTimersByTime(5000)
await flushPromises()
expect(request.post).toHaveBeenCalledTimes(4)
})
test('it should still resend events from the queue even if the timer is cleared before going off', async () => {
const context = makeContext()
const settings = makeSettings()
const transport = new Transport(context, settings)
const postSpy = jest.spyOn(request, 'post').mockRejectedValueOnce(new Error())
transport.log('foo', { bar: 'zaz' }, { url: 'example.com' })
expect(request.post).toHaveBeenCalledTimes(1)
expect(postSpy.mock.calls[0][1].logs).toEqual([expect.objectContaining({ code: 'sdk_foo' })])
await flushPromises()
transport.log('foo2', { bar: 'zaz2' }, { url: 'example2.com' })
expect(request.post).toHaveBeenCalledTimes(2)
expect(postSpy.mock.calls[1][1].logs).toEqual([
expect.objectContaining({ code: 'sdk_foo' }),
expect.objectContaining({ code: 'sdk_foo2' }),
])
await flushPromises()
})
})