diff --git a/integrationExamples/gpt/raynRtdProvider_example.html b/integrationExamples/gpt/raynRtdProvider_example.html
new file mode 100644
index 000000000000..2d43c37513a8
--- /dev/null
+++ b/integrationExamples/gpt/raynRtdProvider_example.html
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rayn RTD Prebid
+
+
+
+
+
+ Rayn Segments:
+
+
+
diff --git a/modules/raynRtdProvider.js b/modules/raynRtdProvider.js
new file mode 100644
index 000000000000..d558c360c4a5
--- /dev/null
+++ b/modules/raynRtdProvider.js
@@ -0,0 +1,198 @@
+/**
+ * This module adds the Rayn provider to the real time data module
+ * The {@link module:modules/realTimeData} module is required
+ * The module will fetch real-time audience and context data from Rayn
+ * @module modules/raynRtdProvider
+ * @requires module:modules/realTimeData
+ */
+
+import { MODULE_TYPE_RTD } from '../src/activities/modules.js';
+import { submodule } from '../src/hook.js';
+import { getStorageManager } from '../src/storageManager.js';
+import { deepAccess, deepSetValue, logError, logMessage, mergeDeep } from '../src/utils.js';
+
+const MODULE_NAME = 'realTimeData';
+const SUBMODULE_NAME = 'rayn';
+const RAYN_TCF_ID = 1220;
+const LOG_PREFIX = 'RaynJS: ';
+export const SEGMENTS_RESOLVER = 'rayn.io';
+export const RAYN_LOCAL_STORAGE_KEY = 'rayn-segtax';
+
+const defaultIntegration = {
+ iabAudienceCategories: {
+ v1_1: {
+ tier: 6,
+ enabled: true,
+ },
+ },
+ iabContentCategories: {
+ v3_0: {
+ tier: 4,
+ enabled: true,
+ },
+ v2_2: {
+ tier: 4,
+ enabled: true,
+ },
+ },
+};
+
+export const storage = getStorageManager({
+ moduleType: MODULE_TYPE_RTD,
+ moduleName: SUBMODULE_NAME,
+});
+
+function init(moduleConfig, userConsent) {
+ return true;
+}
+
+/**
+ * Create and return ORTB2 object with segtax and segments
+ * @param {number} segtax
+ * @param {Array} segmentIds
+ * @param {number} maxTier
+ * @return {Array}
+ */
+export function generateOrtbDataObject(segtax, segment, maxTier) {
+ const segmentIds = [];
+
+ try {
+ Object.keys(segment).forEach(tier => {
+ if (tier <= maxTier) {
+ segmentIds.push(...segment[tier].map((id) => {
+ return { id };
+ }))
+ }
+ });
+ } catch (error) {
+ logError(LOG_PREFIX, error);
+ }
+
+ return {
+ name: SEGMENTS_RESOLVER,
+ ext: {
+ segtax,
+ },
+ segment: segmentIds,
+ };
+}
+
+/**
+ * Generates checksum
+ * @param {string} url
+ * @returns {string}
+ */
+export function generateChecksum(stringValue) {
+ const l = stringValue.length;
+ let i = 0;
+ let h = 0;
+ if (l > 0) while (i < l) h = ((h << 5) - h + stringValue.charCodeAt(i++)) | 0;
+ return h.toString();
+};
+
+/**
+ * Gets an object of segtax and segment IDs from LocalStorage
+ * or return the default value provided.
+ * @param {string} key
+ * @return {Object}
+ */
+export function readSegments(key) {
+ try {
+ return JSON.parse(storage.getDataFromLocalStorage(key));
+ } catch (error) {
+ logError(LOG_PREFIX, error);
+ return null;
+ }
+}
+
+/**
+ * Pass segments to configured bidders, using ORTB2
+ * @param {Object} bidConfig
+ * @param {Array} bidders
+ * @param {Object} integrationConfig
+ * @param {Array} segments
+ * @return {void}
+ */
+export function setSegmentsAsBidderOrtb2(bidConfig, bidders, integrationConfig, segments, checksum) {
+ const raynOrtb2 = {};
+
+ const raynContentData = [];
+ if (integrationConfig.iabContentCategories.v2_2.enabled && segments[checksum] && segments[checksum][6]) {
+ raynContentData.push(generateOrtbDataObject(6, segments[checksum][6], integrationConfig.iabContentCategories.v2_2.tier));
+ }
+ if (integrationConfig.iabContentCategories.v3_0.enabled && segments[checksum] && segments[checksum][7]) {
+ raynContentData.push(generateOrtbDataObject(7, segments[checksum][7], integrationConfig.iabContentCategories.v3_0.tier));
+ }
+ if (raynContentData.length > 0) {
+ deepSetValue(raynOrtb2, 'site.content.data', raynContentData);
+ }
+
+ if (integrationConfig.iabAudienceCategories.v1_1.enabled && segments[4]) {
+ const raynUserData = [generateOrtbDataObject(4, segments[4], integrationConfig.iabAudienceCategories.v1_1.tier)];
+ deepSetValue(raynOrtb2, 'user.data', raynUserData);
+ }
+
+ if (!bidders || bidders.length === 0 || !segments || Object.keys(segments).length <= 0) {
+ mergeDeep(bidConfig?.ortb2Fragments?.global, raynOrtb2);
+ } else {
+ const bidderConfig = Object.fromEntries(
+ bidders.map((bidder) => [bidder, raynOrtb2]),
+ );
+ mergeDeep(bidConfig?.ortb2Fragments?.bidder, bidderConfig);
+ }
+}
+
+/**
+ * Real-time data retrieval from Rayn
+ * @param {Object} reqBidsConfigObj
+ * @param {function} callback
+ * @param {Object} config
+ * @param {Object} userConsent
+ * @return {void}
+ */
+function alterBidRequests(reqBidsConfigObj, callback, config, userConsent) {
+ try {
+ const checksum = generateChecksum(window.location.href);
+
+ const segments = readSegments(RAYN_LOCAL_STORAGE_KEY);
+
+ const bidders = deepAccess(config, 'params.bidders');
+ const integrationConfig = mergeDeep(defaultIntegration, deepAccess(config, 'params.integration'));
+
+ if (segments && Object.keys(segments).length > 0 && (
+ segments[checksum] || (segments[4] &&
+ integrationConfig.iabAudienceCategories.v1_1.enabled &&
+ !integrationConfig.iabContentCategories.v2_2.enabled &&
+ !integrationConfig.iabContentCategories.v3_0.enabled
+ )
+ )) {
+ logMessage(LOG_PREFIX, `Segtax data from localStorage: ${JSON.stringify(segments)}`);
+ setSegmentsAsBidderOrtb2(reqBidsConfigObj, bidders, integrationConfig, segments, checksum);
+ callback();
+ } else if (window.raynJS && typeof window.raynJS.getSegtax === 'function') {
+ window.raynJS.getSegtax().then((segtaxData) => {
+ logMessage(LOG_PREFIX, `Segtax data from RaynJS: ${JSON.stringify(segtaxData)}`);
+ setSegmentsAsBidderOrtb2(reqBidsConfigObj, bidders, integrationConfig, segtaxData, checksum);
+ callback();
+ }).catch((error) => {
+ logError(LOG_PREFIX, error);
+ callback();
+ });
+ } else {
+ logMessage(LOG_PREFIX, 'No segtax data');
+ callback();
+ }
+ } catch (error) {
+ logError(LOG_PREFIX, error);
+ callback();
+ }
+}
+
+export const raynSubmodule = {
+ name: SUBMODULE_NAME,
+ init: init,
+ getBidRequestData: alterBidRequests,
+ gvlid: RAYN_TCF_ID,
+};
+
+submodule(MODULE_NAME, raynSubmodule);
diff --git a/modules/raynRtdProvider.md b/modules/raynRtdProvider.md
new file mode 100644
index 000000000000..8d888a18d1f9
--- /dev/null
+++ b/modules/raynRtdProvider.md
@@ -0,0 +1,118 @@
+---
+layout: page_v2
+title: Rayn RTD Provider
+display_name: Rayn Real Time Data Module
+description: Rayn Real Time Data module appends privacy preserving enhanced contextual categories and audiences. Moments matter.
+page_type: module
+module_type: rtd
+module_code: raynRtdProvider
+enable_download: true
+vendor_specific: true
+sidebarType: 1
+---
+
+# Rayn Real-time Data Submodule
+
+Rayn is a privacy preserving, data platform. We turn content into context, into audiences. For Personalisation, Monetisation and Insights. This module reads contextual categories and audience cohorts from RaynJS (via localStorage) and passes them to the bid-stream.
+
+## Integration
+
+To install the module, follow these instructions:
+
+Step 1: Prepare the base Prebid file
+Compile the Rayn RTD module (`raynRtdProvider`) into your Prebid build along with the parent RTD Module (`rtdModule`). From the command line, run gulp build `gulp build --modules=rtdModule,raynRtdProvider`
+
+Step 2: Set configuration
+Enable Rayn RTD Module using pbjs.setConfig. Example is provided in the Configuration section. See the **Parameter Description** for more detailed information of the configuration parameters.
+
+### Configuration
+
+This module is configured as part of the realTimeData.dataProviders object.
+
+Example format:
+
+```js
+pbjs.setConfig(
+ // ...
+ realTimeData: {
+ auctionDelay: 1000,
+ dataProviders: [
+ {
+ name: "rayn",
+ waitForIt: true,
+ params: {
+ bidders: ["appnexus", "pubmatic"],
+ integration: {
+ iabAudienceCategories: {
+ v1_1: {
+ tier: 6,
+ enabled: true,
+ },
+ },
+ iabContentCategories: {
+ v3_0: {
+ tier: 4,
+ enabled: true,
+ },
+ v2_2: {
+ tier: 4,
+ enabled: true,
+ },
+ },
+ }
+ }
+ }
+ ]
+ }
+ // ...
+}
+```
+
+## Parameter Description
+
+The parameters below provide configurability for general behaviours of the RTD submodule, as well as enabling settings for specific use cases mentioned above (e.g. tiers and bidders).
+
+### Parameters
+
+{: .table .table-bordered .table-striped }
+| Name | Type | Description | Notes |
+| :---------------------------------------------------- | :-------- | :----------------------------------------------------------------------------------- | :---- |
+| name | `String` | RTD sub module name | Always "rayn" |
+| waitForIt | `Boolean` | Required to ensure that the auction is delayed for the module to respond | Optional. Defaults to false but recommended to true |
+| params | `Object` | ||
+| params.bidders | `Array` | Bidders with which to share context and segment information | Optional. In case no bidder is specified Rayn will append data for all bidders |
+| params.integration | `Object` | Controls which IAB taxonomy should be used and up to which category tier | Optional. In case it's not defined, all supported IAB taxonomies and all category tiers will be used |
+| params.integration.iabAudienceCategories | `Object` | ||
+| params.integration.iabAudienceCategories.v1_1 | `Object` | ||
+| params.integration.iabAudienceCategories.v1_1.enabled | `Boolean` | Controls if IAB Audience Taxonomy v1.1 will be used | Optional. Enabled by default |
+| params.integration.iabAudienceCategories.v1_1.tier | `Number` | Controls up to which IAB Audience Taxonomy v1.1 Category tier will be used | Optional. Tier 6 by default |
+| params.integration.iabContentCategories | `Object` | ||
+| params.integration.iabContentCategories.v3_0 | `Object` | ||
+| params.integration.iabContentCategories.v3_0.enabled | `Boolean` | Controls if IAB Content Taxonomy v3.0 will be used | Optional. Enabled by default |
+| params.integration.iabContentCategories.v3_0.tier | `Number` | Controls up to which IAB Content Taxonomy v3.0 Category tier will be used | Optional. Tier 4 by default |
+| params.integration.iabContentCategories.v2_2 | `Object` | ||
+| params.integration.iabContentCategories.v2_2.enabled | `Boolean` | Controls if IAB Content Taxonomy v2.2 will be used | Optional. Enabled by default |
+| params.integration.iabContentCategories.v2_2.tier | `Number` | Controls up to which IAB Content Taxonomy v2.2 Category tier will be used | Optional. Tier 4 by default |
+
+Please note that raynRtdProvider should be integrated into the website along with RaynJS.
+
+## Testing
+
+To view an example of the on page setup:
+
+```bash
+gulp serve-fast --modules=rtdModule,raynRtdProvider,appnexusBidAdapter
+```
+
+Then in your browser access: [http://localhost:9999/integrationExamples/gpt/raynRtdProvider_example.html](http://localhost:9999/integrationExamples/gpt/raynRtdProvider_example.html)
+
+Run the unit tests, just on the Rayn RTD module test file:
+
+```bash
+gulp test --file "test/spec/modules/raynRtdProvider_spec.js"
+```
+
+## Support
+
+If you require further assistance or are interested in discussing the module functionality please reach out to [support@rayn.io](mailto:support@rayn.io).
+You are also able to find more examples and other integration routes on the Rayn documentation site.
diff --git a/test/spec/modules/raynRtdProvider_spec.js b/test/spec/modules/raynRtdProvider_spec.js
new file mode 100644
index 000000000000..69ea316e8b5a
--- /dev/null
+++ b/test/spec/modules/raynRtdProvider_spec.js
@@ -0,0 +1,308 @@
+import * as raynRTD from 'modules/raynRtdProvider.js';
+import { config } from 'src/config.js';
+import * as utils from 'src/utils.js';
+
+const TEST_CHECKSUM = '-1135402174';
+const TEST_URL = 'http://localhost:9876/context.html';
+const TEST_SEGMENTS = {
+ [TEST_CHECKSUM]: {
+ 7: {
+ 2: ['51', '246', '652', '48', '324']
+ }
+ }
+};
+
+const RTD_CONFIG = {
+ auctionDelay: 250,
+ dataProviders: [
+ {
+ name: 'rayn',
+ waitForIt: true,
+ params: {
+ bidders: [],
+ integration: {
+ iabAudienceCategories: {
+ v1_1: {
+ tier: 6,
+ enabled: true,
+ },
+ },
+ iabContentCategories: {
+ v3_0: {
+ tier: 4,
+ enabled: true,
+ },
+ v2_2: {
+ tier: 4,
+ enabled: true,
+ },
+ },
+ }
+ },
+ },
+ ],
+};
+
+describe('rayn RTD Submodule', function () {
+ let getDataFromLocalStorageStub;
+
+ beforeEach(function () {
+ config.resetConfig();
+ getDataFromLocalStorageStub = sinon.stub(
+ raynRTD.storage,
+ 'getDataFromLocalStorage',
+ );
+ });
+
+ afterEach(function () {
+ getDataFromLocalStorageStub.restore();
+ });
+
+ describe('Initialize module', function () {
+ it('should initialize and return true', function () {
+ expect(raynRTD.raynSubmodule.init(RTD_CONFIG.dataProviders[0])).to.equal(
+ true,
+ );
+ });
+ });
+
+ describe('Generate ortb data object', function () {
+ it('should set empty segment array', function () {
+ expect(raynRTD.generateOrtbDataObject(7, 'invalid', 2).segment).to.be.instanceOf(Array).and.lengthOf(0);
+ });
+
+ it('should set segment array', function () {
+ const expectedSegmentIdsMap = TEST_SEGMENTS[TEST_CHECKSUM][7][2].map((id) => {
+ return { id };
+ });
+ expect(raynRTD.generateOrtbDataObject(7, TEST_SEGMENTS[TEST_CHECKSUM][7], 4)).to.deep.equal({
+ name: raynRTD.SEGMENTS_RESOLVER,
+ ext: {
+ segtax: 7,
+ },
+ segment: expectedSegmentIdsMap,
+ });
+ });
+ });
+
+ describe('Generate checksum', function () {
+ it('should generate checksum', function () {
+ expect(raynRTD.generateChecksum(TEST_URL)).to.equal(TEST_CHECKSUM);
+ });
+ });
+
+ describe('Get segments', function () {
+ it('should get segments from local storage', function () {
+ getDataFromLocalStorageStub
+ .withArgs(raynRTD.RAYN_LOCAL_STORAGE_KEY)
+ .returns(JSON.stringify(TEST_SEGMENTS));
+
+ const segments = raynRTD.readSegments(raynRTD.RAYN_LOCAL_STORAGE_KEY);
+
+ expect(segments).to.deep.equal(TEST_SEGMENTS);
+ });
+
+ it('should return null if unable to read and parse data from local storage', function () {
+ const testString = 'test';
+ getDataFromLocalStorageStub
+ .withArgs(raynRTD.RAYN_LOCAL_STORAGE_KEY)
+ .returns(testString);
+
+ const segments = raynRTD.readSegments(raynRTD.RAYN_LOCAL_STORAGE_KEY);
+
+ expect(segments).to.equal(null);
+ });
+ });
+
+ describe('Set segments as bidder ortb2', function () {
+ it('should set global ortb2 config', function () {
+ const globalOrtb2 = {};
+ const bidders = RTD_CONFIG.dataProviders[0].params.bidders;
+ const integrationConfig = RTD_CONFIG.dataProviders[0].params.integration;
+
+ raynRTD.setSegmentsAsBidderOrtb2({ ortb2Fragments: { global: globalOrtb2 } }, bidders, integrationConfig, TEST_SEGMENTS, TEST_CHECKSUM);
+
+ TEST_SEGMENTS[TEST_CHECKSUM]['7']['2'].forEach((id) => {
+ expect(globalOrtb2.site.content.data[0].segment.find(segment => segment.id === id)).to.exist;
+ })
+ });
+
+ it('should set bidder specific ortb2 config', function () {
+ RTD_CONFIG.dataProviders[0].params.bidders = ['appnexus'];
+
+ const bidderOrtb2 = {};
+ const bidders = RTD_CONFIG.dataProviders[0].params.bidders;
+ const integrationConfig = RTD_CONFIG.dataProviders[0].params.integration;
+
+ raynRTD.setSegmentsAsBidderOrtb2({ ortb2Fragments: { bidder: bidderOrtb2 } }, bidders, integrationConfig, TEST_SEGMENTS, TEST_CHECKSUM);
+
+ bidders.forEach((bidder) => {
+ const ortb2 = bidderOrtb2[bidder];
+ TEST_SEGMENTS[TEST_CHECKSUM]['7']['2'].forEach((id) => {
+ expect(ortb2.site.content.data[0].segment.find(segment => segment.id === id)).to.exist;
+ })
+ });
+ });
+
+ it('should set bidder specific ortb2 config with all segments', function () {
+ TEST_SEGMENTS['4'] = {
+ 3: ['4', '17', '72', '612']
+ };
+ TEST_SEGMENTS[TEST_CHECKSUM]['6'] = {
+ 2: ['71', '313'],
+ 4: ['33', '145', '712']
+ };
+
+ const bidderOrtb2 = {};
+ const bidders = RTD_CONFIG.dataProviders[0].params.bidders;
+ const integrationConfig = RTD_CONFIG.dataProviders[0].params.integration;
+
+ raynRTD.setSegmentsAsBidderOrtb2({ ortb2Fragments: { bidder: bidderOrtb2 } }, bidders, integrationConfig, TEST_SEGMENTS, TEST_CHECKSUM);
+
+ bidders.forEach((bidder) => {
+ const ortb2 = bidderOrtb2[bidder];
+
+ TEST_SEGMENTS[TEST_CHECKSUM]['6']['2'].forEach((id) => {
+ expect(ortb2.site.content.data[0].segment.find(segment => segment.id === id)).to.exist;
+ });
+ TEST_SEGMENTS[TEST_CHECKSUM]['6']['4'].forEach((id) => {
+ expect(ortb2.site.content.data[0].segment.find(segment => segment.id === id)).to.exist;
+ });
+ TEST_SEGMENTS[TEST_CHECKSUM]['7']['2'].forEach((id) => {
+ expect(ortb2.site.content.data[1].segment.find(segment => segment.id === id)).to.exist;
+ });
+ TEST_SEGMENTS['4']['3'].forEach((id) => {
+ expect(ortb2.user.data[0].segment.find(segment => segment.id === id)).to.exist;
+ });
+ });
+ });
+ });
+
+ describe('Alter Bid Requests', function () {
+ it('should update reqBidsConfigObj and execute callback', function () {
+ const callbackSpy = sinon.spy();
+ const logMessageSpy = sinon.spy(utils, 'logMessage');
+
+ getDataFromLocalStorageStub
+ .withArgs(raynRTD.RAYN_LOCAL_STORAGE_KEY)
+ .returns(JSON.stringify(TEST_SEGMENTS));
+
+ const reqBidsConfigObj = { ortb2Fragments: { bidder: {} } };
+
+ raynRTD.raynSubmodule.getBidRequestData(reqBidsConfigObj, callbackSpy, RTD_CONFIG);
+
+ expect(callbackSpy.calledOnce).to.be.true;
+ expect(logMessageSpy.lastCall.lastArg).to.equal(`Segtax data from localStorage: ${JSON.stringify(TEST_SEGMENTS)}`);
+
+ logMessageSpy.restore();
+ });
+
+ it('should update reqBidsConfigObj and execute callback using user segments from localStorage', function () {
+ const callbackSpy = sinon.spy();
+ const logMessageSpy = sinon.spy(utils, 'logMessage');
+ const testSegments = {
+ 4: {
+ 3: ['4', '17', '72', '612']
+ }
+ };
+
+ getDataFromLocalStorageStub
+ .withArgs(raynRTD.RAYN_LOCAL_STORAGE_KEY)
+ .returns(JSON.stringify(testSegments));
+
+ RTD_CONFIG.dataProviders[0].params.integration.iabContentCategories = {
+ v3_0: {
+ enabled: false,
+ },
+ v2_2: {
+ enabled: false,
+ },
+ };
+
+ const reqBidsConfigObj = { ortb2Fragments: { bidder: {} } };
+
+ raynRTD.raynSubmodule.getBidRequestData(reqBidsConfigObj, callbackSpy, RTD_CONFIG.dataProviders[0]);
+
+ expect(callbackSpy.calledOnce).to.be.true;
+ expect(logMessageSpy.lastCall.lastArg).to.equal(`Segtax data from localStorage: ${JSON.stringify(testSegments)}`);
+
+ logMessageSpy.restore();
+ });
+
+ it('should update reqBidsConfigObj and execute callback using segments from raynJS', function () {
+ const callbackSpy = sinon.spy();
+ const logMessageSpy = sinon.spy(utils, 'logMessage');
+
+ getDataFromLocalStorageStub
+ .withArgs(raynRTD.RAYN_LOCAL_STORAGE_KEY)
+ .returns(null);
+
+ const reqBidsConfigObj = { ortb2Fragments: { bidder: {} } };
+
+ raynRTD.raynSubmodule.getBidRequestData(reqBidsConfigObj, callbackSpy, RTD_CONFIG.dataProviders[0]);
+
+ expect(callbackSpy.calledOnce).to.be.true;
+ expect(logMessageSpy.lastCall.lastArg).to.equal(`No segtax data`);
+
+ logMessageSpy.restore();
+ });
+
+ it('should update reqBidsConfigObj and execute callback using audience from localStorage', function (done) {
+ const callbackSpy = sinon.spy();
+ const logMessageSpy = sinon.spy(utils, 'logMessage');
+ const testSegments = {
+ 6: {
+ 4: ['3', '27', '177']
+ }
+ };
+
+ global.window.raynJS = {
+ getSegtax: function () {
+ return Promise.resolve(testSegments);
+ }
+ };
+
+ getDataFromLocalStorageStub
+ .withArgs(raynRTD.RAYN_LOCAL_STORAGE_KEY)
+ .returns(null);
+
+ const reqBidsConfigObj = { ortb2Fragments: { bidder: {} } };
+
+ raynRTD.raynSubmodule.getBidRequestData(reqBidsConfigObj, callbackSpy, RTD_CONFIG.dataProviders[0]);
+
+ setTimeout(() => {
+ expect(callbackSpy.calledOnce).to.be.true;
+ expect(logMessageSpy.lastCall.lastArg).to.equal(`Segtax data from RaynJS: ${JSON.stringify(testSegments)}`);
+ logMessageSpy.restore();
+ done();
+ }, 0)
+ });
+
+ it('should execute callback if log error', function (done) {
+ const callbackSpy = sinon.spy();
+ const logErrorSpy = sinon.spy(utils, 'logError');
+ const rejectError = 'Error';
+
+ global.window.raynJS = {
+ getSegtax: function () {
+ return Promise.reject(rejectError);
+ }
+ };
+
+ getDataFromLocalStorageStub
+ .withArgs(raynRTD.RAYN_LOCAL_STORAGE_KEY)
+ .returns(null);
+
+ const reqBidsConfigObj = { ortb2Fragments: { bidder: {} } };
+
+ raynRTD.raynSubmodule.getBidRequestData(reqBidsConfigObj, callbackSpy, RTD_CONFIG.dataProviders[0]);
+
+ setTimeout(() => {
+ expect(callbackSpy.calledOnce).to.be.true;
+ expect(logErrorSpy.lastCall.lastArg).to.equal(rejectError);
+ logErrorSpy.restore();
+ done();
+ }, 0)
+ });
+ });
+});