diff --git a/cspell.json b/cspell.json index b24dc067..1ec8fb2d 100644 --- a/cspell.json +++ b/cspell.json @@ -39,11 +39,13 @@ "yoink", "searchandized", "searchandizing", + "searchandising", "patchdelta", "searchabilities", "forcesync", "forcedelta", "forcepatchdelta", - "skippable" + "skippable", + "timeofday" ] } diff --git a/package-lock.json b/package-lock.json index 40ee4fb3..7dd00feb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "devDependencies": { "@cspell/eslint-plugin": "^6.8.2", "@types/events": "^3.0.0", + "@types/node": "^22.19.21", "@typescript-eslint/eslint-plugin": "^5.46.1", "@typescript-eslint/parser": "^5.46.1", "chai": "^4.3.4", @@ -1408,6 +1409,16 @@ "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", "dev": true }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", @@ -7074,6 +7085,13 @@ "integrity": "sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g==", "dev": true }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -8541,6 +8559,15 @@ "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", "dev": true }, + "@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "requires": { + "undici-types": "~6.21.0" + } + }, "@types/normalize-package-data": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", @@ -12717,6 +12744,12 @@ "integrity": "sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g==", "dev": true }, + "undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, "unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", diff --git a/package.json b/package.json index 8dea0550..196c5587 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "devDependencies": { "@cspell/eslint-plugin": "^6.8.2", "@types/events": "^3.0.0", + "@types/node": "^22.19.21", "@typescript-eslint/eslint-plugin": "^5.46.1", "@typescript-eslint/parser": "^5.46.1", "chai": "^4.3.4", diff --git a/spec/src/modules/searchandising.js b/spec/src/modules/searchandising.js new file mode 100644 index 00000000..dc15ed9c --- /dev/null +++ b/spec/src/modules/searchandising.js @@ -0,0 +1,1076 @@ +/* eslint-disable no-unused-expressions, import/no-unresolved, no-restricted-syntax, max-nested-callbacks */ +const dotenv = require('dotenv'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const cloneDeep = require('lodash.clonedeep'); +const ConstructorIO = require('../../../test/constructorio'); // eslint-disable-line import/extensions +const helpers = require('../../mocha.helpers'); + +const nodeFetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); + +chai.use(chaiAsPromised); +chai.use(sinonChai); +dotenv.config(); + +const sendTimeout = 300; +const testApiKey = process.env.TEST_CATALOG_API_KEY; +const testApiToken = process.env.TEST_API_TOKEN; +const validOptions = { + apiKey: testApiKey, + apiToken: testApiToken, +}; +const skipNetworkTimeoutTests = process.env.SKIP_NETWORK_TIMEOUT_TESTS === 'true'; + +describe('ConstructorIO - Searchandising', function ConstructorIOSearchandising() { + // Ensure Mocha doesn't time out waiting for operation to complete + this.timeout(10000); + + const clientVersion = 'cio-mocha'; + // Track campaigns created during the suite so they can be cleaned up afterwards + const createdCampaignIds = []; + let fetchSpy; + let campaignId; + + before(async () => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + }); + + // Save id of an existing campaign for the following tests below + await searchandising.campaigns.retrieveCampaigns({ numResultsPerPage: 1 }).then((res) => { + if (res.campaigns && res.campaigns.length) { + campaignId = res.campaigns[0].id; + } + }); + + // Create a campaign if none exists so the tests below have an id to work with + if (!campaignId) { + const created = await searchandising.campaigns.createCampaign({ name: `test-campaign-${Date.now()}`, refinedQueries: [{ query: 'test-query' }] }); + + campaignId = created.id; + createdCampaignIds.push(created.id); + } + }); + + after(async () => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + }); + + // Clean up campaigns created during the suite (in parallel to stay within the hook timeout) + await Promise.all(createdCampaignIds.map((id) => searchandising.campaigns.deleteCampaign({ id }).catch(() => {}))); + }); + + beforeEach(() => { + global.CLIENT_VERSION = clientVersion; + fetchSpy = sinon.spy(nodeFetch); + }); + + afterEach((done) => { + delete global.CLIENT_VERSION; + + fetchSpy = null; + + setTimeout(done, sendTimeout); + }); + + describe('retrieveCampaigns', () => { + it('Should retrieve a list of campaigns', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns().then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + expect(res).to.have.property('total_count').to.be.a('number'); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given section', (done) => { + const section = 'Search Suggestions'; + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ section }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('section').to.equal(section); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given page and results per page', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ page: 1, numResultsPerPage: 50 }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('page').to.equal('1'); + expect(requestedUrlParams).to.have.property('num_results_per_page').to.equal('50'); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + expect(res.campaigns.length).to.be.lte(50); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given page and results per page when passing parameters snake_case', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ page: 1, num_results_per_page: 50 }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('num_results_per_page').to.equal('50'); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + expect(res.campaigns.length).to.be.lte(50); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given offset', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ offset: 1 }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('offset').to.equal('1'); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given a single id', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ id: campaignId }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('id').to.equal(String(campaignId)); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given multiple ids', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ id: [campaignId, campaignId] }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('id').to.deep.equal([String(campaignId), String(campaignId)]); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given refinedFilters', (done) => { + const refinedFilters = { group_id: ['123', '456'] }; + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ refinedFilters }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('refined_filters'); + expect(requestedUrlParams.refined_filters).to.have.property('group_id').to.deep.equal(['123', '456']); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given refined_filters (snake_case)', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ refined_filters: { group_id: ['123', '456'] } }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('refined_filters'); + expect(requestedUrlParams.refined_filters).to.have.property('group_id').to.deep.equal(['123', '456']); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given refinedRecommendationContexts', (done) => { + const refinedRecommendationContexts = { pod_id: ['pod-a', 'pod-b'] }; + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ refinedRecommendationContexts }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('refined_recommendation_contexts'); + expect(requestedUrlParams.refined_recommendation_contexts).to.have.property('pod_id').to.deep.equal(['pod-a', 'pod-b']); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given refined_recommendation_contexts (snake_case)', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ refined_recommendation_contexts: { pod_id: ['pod-a', 'pod-b'] } }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('refined_recommendation_contexts'); + expect(requestedUrlParams.refined_recommendation_contexts).to.have.property('pod_id').to.deep.equal(['pod-a', 'pod-b']); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given refinedQueries', (done) => { + const refinedQueries = ['shoes', 'boots']; + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ refinedQueries }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('refined_queries').to.deep.equal(refinedQueries); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + + done(); + }); + }); + + it('Should retrieve a list of campaigns given refined_queries (snake_case)', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({ refined_queries: ['shoes', 'boots'] }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('refined_queries').to.deep.equal(['shoes', 'boots']); + + // Response + expect(res).to.have.property('campaigns').to.be.an('array'); + + done(); + }); + }); + + it('Should pass the correct custom headers passed in function networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaigns({}, { headers: { 'X-Constructor-IO-Test': 'test' } }).then(() => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test'); + done(); + }); + }); + + it('Should pass the correct custom headers passed in global networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + networkParameters: { + headers: { + 'X-Constructor-IO-Test': 'test', + }, + }, + }); + + searchandising.campaigns.retrieveCampaigns().then(() => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test'); + done(); + }); + }); + + it('Should combine custom headers from function networkParameters and global networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + networkParameters: { + headers: { + 'X-Constructor-IO-Test': 'test', + 'X-Constructor-IO-Test-Another': 'test', + }, + }, + }); + + searchandising.campaigns.retrieveCampaigns({}, { headers: { 'X-Constructor-IO-Test': 'test2' } }).then(() => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test2'); + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test-Another').to.equal('test'); + done(); + }); + }); + + it('Should return error when retrieving a list of campaigns with an invalid API key', () => { + const invalidOptions = cloneDeep(validOptions); + invalidOptions.apiKey = 'notanapikey'; + + const { searchandising } = new ConstructorIO({ + ...invalidOptions, + fetch: fetchSpy, + }); + + return expect(searchandising.campaigns.retrieveCampaigns()).to.eventually.be.rejected; + }); + + it('Should return error when retrieving a list of campaigns with an invalid API token', () => { + const invalidOptions = cloneDeep(validOptions); + invalidOptions.apiToken = 'notanapitoken'; + + const { searchandising } = new ConstructorIO({ + ...invalidOptions, + fetch: fetchSpy, + }); + + return expect(searchandising.campaigns.retrieveCampaigns()).to.eventually.be.rejected; + }); + + if (!skipNetworkTimeoutTests) { + it('Should be rejected when network request timeout is provided and reached', () => { + const { searchandising } = new ConstructorIO(validOptions); + + return expect(searchandising.campaigns.retrieveCampaigns({}, { timeout: 10 })).to.eventually.be.rejectedWith('The operation was aborted.'); + }); + + it('Should be rejected when global network request timeout is provided and reached', () => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + networkParameters: { timeout: 20 }, + }); + + return expect(searchandising.campaigns.retrieveCampaigns()).to.eventually.be.rejectedWith('The operation was aborted.'); + }); + } + }); + + describe('retrieveCampaign', () => { + it('Should retrieve a campaign given a specific id', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaign({ id: campaignId }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + + // Response + expect(res).to.have.property('id').to.equal(campaignId); + + done(); + }); + }); + + it('Should retrieve a campaign given a specific id and section', (done) => { + const section = 'Products'; + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaign({ id: campaignId, section }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('section').to.equal(section); + + // Response + expect(res).to.have.property('id').to.equal(campaignId); + + done(); + }); + }); + + it('Should pass the correct custom headers passed in function networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.retrieveCampaign({ id: campaignId }, { headers: { 'X-Constructor-IO-Test': 'test' } }).then((res) => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test'); + + // Response + expect(res).to.have.property('id').to.equal(campaignId); + + done(); + }); + }); + + it('Should pass the correct custom headers passed in global networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + networkParameters: { + headers: { + 'X-Constructor-IO-Test': 'test', + }, + }, + }); + + searchandising.campaigns.retrieveCampaign({ id: campaignId }).then((res) => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test'); + + // Response + expect(res).to.have.property('id').to.equal(campaignId); + + done(); + }); + }); + + it('Should combine custom headers from function networkParameters and global networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + networkParameters: { + headers: { + 'X-Constructor-IO-Test': 'test', + 'X-Constructor-IO-Test-Another': 'test', + }, + }, + }); + + searchandising.campaigns.retrieveCampaign({ id: campaignId }, { headers: { 'X-Constructor-IO-Test': 'test2' } }).then((res) => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test2'); + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test-Another').to.equal('test'); + + // Response + expect(res).to.have.property('id').to.equal(campaignId); + + done(); + }); + }); + + it('Should return error when retrieving a campaign given a specific id with an invalid API key', () => { + const invalidOptions = cloneDeep(validOptions); + invalidOptions.apiKey = 'notanapikey'; + + const { searchandising } = new ConstructorIO({ + ...invalidOptions, + fetch: fetchSpy, + }); + + return expect(searchandising.campaigns.retrieveCampaign({ id: campaignId })).to.eventually.be.rejected; + }); + + it('Should return error when retrieving a campaign given a specific id with an invalid API token', () => { + const invalidOptions = cloneDeep(validOptions); + invalidOptions.apiToken = 'notanapitoken'; + + const { searchandising } = new ConstructorIO({ + ...invalidOptions, + fetch: fetchSpy, + }); + + return expect(searchandising.campaigns.retrieveCampaign({ id: campaignId })).to.eventually.be.rejected; + }); + + if (!skipNetworkTimeoutTests) { + it('Should be rejected when network request timeout is provided and reached', () => { + const { searchandising } = new ConstructorIO(validOptions); + + return expect(searchandising.campaigns.retrieveCampaign({ id: campaignId }, { timeout: 10 })).to.eventually.be.rejectedWith('The operation was aborted.'); + }); + + it('Should be rejected when global network request timeout is provided and reached', () => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + networkParameters: { timeout: 20 }, + }); + + return expect(searchandising.campaigns.retrieveCampaign({ id: campaignId })).to.eventually.be.rejectedWith('The operation was aborted.'); + }); + } + }); + + describe('createCampaign', () => { + const createCampaignName = `test-campaign-${Date.now()}`; + + it('Should create a campaign given a name', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.createCampaign({ name: createCampaignName, refinedQueries: [{ query: 'test-query' }] }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + const requestedBody = helpers.extractBodyParamsFromFetch(fetchSpy); + + createdCampaignIds.push(res.id); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedBody).to.have.property('name').to.equal(createCampaignName); + + // Response + expect(res).to.have.property('id').to.be.a('number'); + expect(res).to.have.property('name').to.equal(createCampaignName); + + done(); + }); + }); + + it('Should create a campaign given a name, description and section', (done) => { + const section = 'Products'; + const description = 'Seasonal promotion campaign'; + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.createCampaign({ name: `${createCampaignName}-2`, description, section, refinedQueries: [{ query: 'test-query' }] }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + const requestedBody = helpers.extractBodyParamsFromFetch(fetchSpy); + + createdCampaignIds.push(res.id); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('section').to.equal(section); + expect(requestedBody).to.have.property('description').to.equal(description); + expect(requestedBody).to.not.have.property('section'); + + // Response + expect(res).to.have.property('id').to.be.a('number'); + expect(res).to.have.property('description').to.equal(description); + + done(); + }); + }); + + it('Should pass the correct custom headers passed in function networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.createCampaign({ name: `${createCampaignName}-4`, refinedQueries: [{ query: 'test-query' }] }, { headers: { 'X-Constructor-IO-Test': 'test' } }).then((res) => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + createdCampaignIds.push(res.id); + + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test'); + done(); + }); + }); + + it('Should pass the correct custom headers passed in global networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + networkParameters: { + headers: { + 'X-Constructor-IO-Test': 'test', + }, + }, + }); + + searchandising.campaigns.createCampaign({ name: `${createCampaignName}-5`, refinedQueries: [{ query: 'test-query' }] }).then((res) => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + createdCampaignIds.push(res.id); + + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test'); + done(); + }); + }); + + it('Should combine custom headers from function networkParameters and global networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + networkParameters: { + headers: { + 'X-Constructor-IO-Test': 'test', + 'X-Constructor-IO-Test-Another': 'test', + }, + }, + }); + + searchandising.campaigns.createCampaign({ name: `${createCampaignName}-6`, refinedQueries: [{ query: 'test-query' }] }, { headers: { 'X-Constructor-IO-Test': 'test2' } }).then((res) => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + createdCampaignIds.push(res.id); + + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test2'); + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test-Another').to.equal('test'); + done(); + }); + }); + + it('Should return error when creating a campaign with an invalid API key', () => { + const invalidOptions = cloneDeep(validOptions); + invalidOptions.apiKey = 'notanapikey'; + + const { searchandising } = new ConstructorIO({ + ...invalidOptions, + fetch: fetchSpy, + }); + + return expect(searchandising.campaigns.createCampaign({ name: `${createCampaignName}-invalid-key`, refinedQueries: [{ query: 'test-query' }] })).to.eventually.be.rejected; + }); + + it('Should return error when creating a campaign with an invalid API token', () => { + const invalidOptions = cloneDeep(validOptions); + invalidOptions.apiToken = 'notanapitoken'; + + const { searchandising } = new ConstructorIO({ + ...invalidOptions, + fetch: fetchSpy, + }); + + return expect(searchandising.campaigns.createCampaign({ name: `${createCampaignName}-invalid-token`, refinedQueries: [{ query: 'test-query' }] })).to.eventually.be.rejected; + }); + + if (!skipNetworkTimeoutTests) { + it('Should be rejected when network request timeout is provided and reached', () => { + const { searchandising } = new ConstructorIO(validOptions); + + return expect(searchandising.campaigns.createCampaign({ name: `${createCampaignName}-timeout`, refinedQueries: [{ query: 'test-query' }] }, { timeout: 10 })).to.eventually.be.rejectedWith('The operation was aborted.'); + }); + + it('Should be rejected when global network request timeout is provided and reached', () => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + networkParameters: { timeout: 20 }, + }); + + return expect(searchandising.campaigns.createCampaign({ name: `${createCampaignName}-global-timeout`, refinedQueries: [{ query: 'test-query' }] })).to.eventually.be.rejectedWith('The operation was aborted.'); + }); + } + }); + + describe('updateCampaign', () => { + let updateCampaignId; + + before(async () => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + }); + + // Create a campaign to update in the following tests below + await searchandising.campaigns.createCampaign({ name: `test-campaign-update-${Date.now()}`, refinedQueries: [{ query: 'test-query' }] }).then((res) => { + updateCampaignId = res.id; + createdCampaignIds.push(res.id); + }); + }); + + it('Should update a campaign given an id and name', (done) => { + const updatedName = `updated-campaign-${Date.now()}`; + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.updateCampaign({ id: updateCampaignId, name: updatedName }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + const requestedBody = helpers.extractBodyParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedBody).to.have.property('name').to.equal(updatedName); + expect(requestedBody).to.not.have.property('id'); + + // Response + expect(res).to.have.property('id').to.equal(updateCampaignId); + expect(res).to.have.property('name').to.equal(updatedName); + + done(); + }); + }); + + it('Should update a campaign given a description and section', (done) => { + const section = 'Products'; + const description = 'Updated seasonal promotion campaign'; + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.updateCampaign({ id: updateCampaignId, description, section }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + const requestedBody = helpers.extractBodyParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('section').to.equal(section); + expect(requestedBody).to.have.property('description').to.equal(description); + expect(requestedBody).to.not.have.property('section'); + expect(requestedBody).to.not.have.property('id'); + + // Response + expect(res).to.have.property('id').to.equal(updateCampaignId); + expect(res).to.have.property('description').to.equal(description); + + done(); + }); + }); + + it('Should pass the correct custom headers passed in function networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + searchandising.campaigns.updateCampaign({ id: updateCampaignId }, { headers: { 'X-Constructor-IO-Test': 'test' } }).then(() => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test'); + done(); + }); + }); + + it('Should pass the correct custom headers passed in global networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + networkParameters: { + headers: { + 'X-Constructor-IO-Test': 'test', + }, + }, + }); + + searchandising.campaigns.updateCampaign({ id: updateCampaignId }).then(() => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test'); + done(); + }); + }); + + it('Should combine custom headers from function networkParameters and global networkParameters', (done) => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + networkParameters: { + headers: { + 'X-Constructor-IO-Test': 'test', + 'X-Constructor-IO-Test-Another': 'test', + }, + }, + }); + + searchandising.campaigns.updateCampaign({ id: updateCampaignId }, { headers: { 'X-Constructor-IO-Test': 'test2' } }).then(() => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test2'); + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test-Another').to.equal('test'); + done(); + }); + }); + + it('Should return error when updating a campaign with an invalid API key', () => { + const invalidOptions = cloneDeep(validOptions); + invalidOptions.apiKey = 'notanapikey'; + + const { searchandising } = new ConstructorIO({ + ...invalidOptions, + fetch: fetchSpy, + }); + + return expect(searchandising.campaigns.updateCampaign({ id: updateCampaignId, name: 'updated' })).to.eventually.be.rejected; + }); + + it('Should return error when updating a campaign with an invalid API token', () => { + const invalidOptions = cloneDeep(validOptions); + invalidOptions.apiToken = 'notanapitoken'; + + const { searchandising } = new ConstructorIO({ + ...invalidOptions, + fetch: fetchSpy, + }); + + return expect(searchandising.campaigns.updateCampaign({ id: updateCampaignId, name: 'updated' })).to.eventually.be.rejected; + }); + + if (!skipNetworkTimeoutTests) { + it('Should be rejected when network request timeout is provided and reached', () => { + const { searchandising } = new ConstructorIO(validOptions); + + return expect(searchandising.campaigns.updateCampaign({ id: updateCampaignId, name: 'updated' }, { timeout: 10 })).to.eventually.be.rejectedWith('The operation was aborted.'); + }); + + it('Should be rejected when global network request timeout is provided and reached', () => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + networkParameters: { timeout: 20 }, + }); + + return expect(searchandising.campaigns.updateCampaign({ id: updateCampaignId, name: 'updated' })).to.eventually.be.rejectedWith('The operation was aborted.'); + }); + } + }); + + describe('deleteCampaign', () => { + // Create a fresh campaign before each test so it can be safely deleted + async function createCampaignToDelete() { + const { searchandising } = new ConstructorIO({ + ...validOptions, + }); + + const res = await searchandising.campaigns.createCampaign({ name: `test-campaign-delete-${Date.now()}-${Math.random()}`, refinedQueries: [{ query: 'test-query' }] }); + + return res.id; + } + + it('Should delete a campaign given an id', async () => { + const idToDelete = await createCampaignToDelete(); + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + await searchandising.campaigns.deleteCampaign({ id: idToDelete }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + + // Response + expect(res).to.equal(undefined); + }); + }); + + it('Should delete a campaign given an id and section', async () => { + const section = 'Products'; + const idToDelete = await createCampaignToDelete(); + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + await searchandising.campaigns.deleteCampaign({ id: idToDelete, section }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('section').to.equal(section); + + // Response + expect(res).to.equal(undefined); + }); + }); + + it('Should pass the correct custom headers passed in function networkParameters', async () => { + const idToDelete = await createCampaignToDelete(); + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + }); + + await searchandising.campaigns.deleteCampaign({ id: idToDelete }, { headers: { 'X-Constructor-IO-Test': 'test' } }).then(() => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test'); + }); + }); + + it('Should pass the correct custom headers passed in global networkParameters', async () => { + const idToDelete = await createCampaignToDelete(); + const { searchandising } = new ConstructorIO({ + ...validOptions, + fetch: fetchSpy, + networkParameters: { + headers: { + 'X-Constructor-IO-Test': 'test', + }, + }, + }); + + await searchandising.campaigns.deleteCampaign({ id: idToDelete }).then(() => { + const requestedHeaders = helpers.extractHeadersFromFetch(fetchSpy); + + expect(fetchSpy).to.have.been.called; + expect(requestedHeaders).to.have.property('X-Constructor-IO-Test').to.equal('test'); + }); + }); + + it('Should return error when deleting a campaign with an invalid API key', () => { + const invalidOptions = cloneDeep(validOptions); + invalidOptions.apiKey = 'notanapikey'; + + const { searchandising } = new ConstructorIO({ + ...invalidOptions, + fetch: fetchSpy, + }); + + return expect(searchandising.campaigns.deleteCampaign({ id: campaignId })).to.eventually.be.rejected; + }); + + it('Should return error when deleting a campaign with an invalid API token', () => { + const invalidOptions = cloneDeep(validOptions); + invalidOptions.apiToken = 'notanapitoken'; + + const { searchandising } = new ConstructorIO({ + ...invalidOptions, + fetch: fetchSpy, + }); + + return expect(searchandising.campaigns.deleteCampaign({ id: campaignId })).to.eventually.be.rejected; + }); + + if (!skipNetworkTimeoutTests) { + it('Should be rejected when network request timeout is provided and reached', () => { + const { searchandising } = new ConstructorIO(validOptions); + + return expect(searchandising.campaigns.deleteCampaign({ id: campaignId }, { timeout: 10 })).to.eventually.be.rejectedWith('The operation was aborted.'); + }); + + it('Should be rejected when global network request timeout is provided and reached', () => { + const { searchandising } = new ConstructorIO({ + ...validOptions, + networkParameters: { timeout: 20 }, + }); + + return expect(searchandising.campaigns.deleteCampaign({ id: campaignId })).to.eventually.be.rejectedWith('The operation was aborted.'); + }); + } + }); +}); diff --git a/src/constructorio.js b/src/constructorio.js index 9fff6963..a8864197 100644 --- a/src/constructorio.js +++ b/src/constructorio.js @@ -8,6 +8,7 @@ const Autocomplete = require('./modules/autocomplete'); const Recommendations = require('./modules/recommendations'); const Tasks = require('./modules/tasks'); const Quizzes = require('./modules/quizzes'); +const Searchandising = require('./modules/searchandising'); const { version: packageVersion } = require('../package.json'); const utils = require('./utils/helpers'); @@ -42,6 +43,7 @@ class ConstructorIO { * @property {object} catalog - Interface to {@link module:catalog} * @property {object} tasks - Interface to {@link module:tasks} * @property {object} quizzes - Interface to {@link module:quizzes} + * @property {object} searchandising - Interface to {@link module:searchandising} * @returns {class} */ constructor(options = {}) { @@ -83,6 +85,7 @@ class ConstructorIO { this.recommendations = new Recommendations(this.options); this.tasks = new Tasks(this.options); this.quizzes = new Quizzes(this.options); + this.searchandising = new Searchandising(this.options); } } diff --git a/src/modules/searchandising/campaigns.js b/src/modules/searchandising/campaigns.js new file mode 100644 index 00000000..b4d3c7e9 --- /dev/null +++ b/src/modules/searchandising/campaigns.js @@ -0,0 +1,428 @@ +/* eslint-disable max-len */ +/* eslint-disable camelcase, no-underscore-dangle, no-unneeded-ternary, brace-style */ +const qs = require('qs'); +const { AbortController } = require('node-abort-controller'); +const helpers = require('../../utils/helpers'); + +// Create URL from supplied path and options +function createCampaignsUrl(path, options, additionalQueryParams = {}, apiVersion = 'v1') { + const { + apiKey, + serviceUrl, + version, + } = options; + let queryParams = { + c: version, + ...additionalQueryParams, + }; + + // Validate path is provided + if (!path || typeof path !== 'string') { + throw new Error('path is a required parameter of type string'); + } + + queryParams.key = apiKey; + queryParams = helpers.cleanParams(queryParams); + + const queryString = qs.stringify(queryParams, { indices: false }); + + const encodedPath = path.split('/').map(encodeURIComponent).join('/'); + + return `${serviceUrl}/${encodeURIComponent(apiVersion)}/${encodedPath}?${queryString}`; +} + +/** + * Interface to searchandising campaign related API calls + * + * @module searchandising/campaigns + * @inner + * @returns {object} + */ +class Campaigns { + constructor(options) { + this.options = options || {}; + } + + /** + * Retrieve campaigns + * + * @function retrieveCampaigns + * @param {object} [parameters] - Additional parameters for retrieving campaigns + * @param {string} [parameters.section='Products'] - The section of the index to use + * @param {number|number[]} [parameters.id] - The ID(s) of campaigns to filter by + * @param {string[]} [parameters.refinedQueries] - A list of refined queries to filter by + * @param {object} [parameters.refinedFilters] - An object of refined filters to filter by + * @param {object} [parameters.refinedRecommendationContexts] - A filter for refined recommendation contexts + * @param {number} [parameters.numResultsPerPage=20] - The number of campaigns to return - maximum value 100 + * @param {number} [parameters.page] - The page of results to return + * @param {number} [parameters.offset] - The number of results to skip from the beginning - cannot be used together with `page` + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {Promise} + * @see https://docs.constructor.com/reference/v1-searchandising-retrieve-campaigns + * @example + * constructorio.searchandising.campaigns.retrieveCampaigns({ + * section: 'Products', + * numResultsPerPage: 50, + * page: 1, + * }); + */ + retrieveCampaigns(parameters = {}, networkParameters = {}) { + const queryParams = {}; + let requestUrl; + const { fetch } = this.options; + const controller = new AbortController(); + const { signal } = controller; + const headers = { + 'Content-Type': 'application/json', + }; + + if (parameters) { + const { + section, + id, + refined_filters, + refinedFilters = refined_filters, + num_results_per_page, + numResultsPerPage = num_results_per_page, + page, + offset, + refined_recommendation_contexts, + refinedRecommendationContexts = refined_recommendation_contexts, + refined_queries, + refinedQueries = refined_queries, + } = parameters; + + if (section) { + queryParams.section = section; + } + + if (id) { + queryParams.id = id; + } + + if (refinedFilters) { + queryParams.refined_filters = refinedFilters; + } + + if (numResultsPerPage) { + queryParams.num_results_per_page = numResultsPerPage; + } + + if (page) { + queryParams.page = page; + } + + if (offset) { + queryParams.offset = offset; + } + + if (refinedRecommendationContexts) { + queryParams.refined_recommendation_contexts = refinedRecommendationContexts; + } + + if (refinedQueries) { + queryParams.refined_queries = refinedQueries; + } + } + + try { + requestUrl = createCampaignsUrl('campaigns', this.options, queryParams); + } catch (e) { + return Promise.reject(e); + } + + Object.assign(headers, helpers.combineCustomHeaders(this.options, networkParameters)); + + helpers.applyNetworkTimeout(this.options, networkParameters, controller); + + return fetch(requestUrl, { + method: 'GET', + headers: { + ...headers, + ...helpers.createAuthHeader(this.options), + }, + signal, + }).then((response) => { + if (response.ok) { + return response.json(); + } + + return helpers.throwHttpErrorFromResponse(new Error(), response); + }).then((json) => json); + } + + /** + * Retrieve a campaign given a specific id + * + * @function retrieveCampaign + * @param {object} parameters - Additional parameters for retrieving a campaign + * @param {number} parameters.id - The ID of the campaign to be retrieved + * @param {string} [parameters.section='Products'] - The section of the index to use + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {Promise} + * @see https://docs.constructor.com/reference/v1-searchandising-retrieve-campaign + * @example + * constructorio.searchandising.campaigns.retrieveCampaign({ + * id: 42, + * section: 'Products', + * }); + */ + retrieveCampaign(parameters = {}, networkParameters = {}) { + const queryParams = {}; + let requestUrl; + const { fetch } = this.options; + const controller = new AbortController(); + const { signal } = controller; + const headers = { + 'Content-Type': 'application/json', + }; + const { id, section } = parameters; + + if (section) { + queryParams.section = section; + } + + try { + requestUrl = createCampaignsUrl(`campaigns/${id}`, this.options, queryParams); + } catch (e) { + return Promise.reject(e); + } + + Object.assign(headers, helpers.combineCustomHeaders(this.options, networkParameters)); + + helpers.applyNetworkTimeout(this.options, networkParameters, controller); + + return fetch(requestUrl, { + method: 'GET', + headers: { + ...headers, + ...helpers.createAuthHeader(this.options), + }, + signal, + }).then((response) => { + if (response.ok) { + return response.json(); + } + + return helpers.throwHttpErrorFromResponse(new Error(), response); + }).then((json) => json); + } + + /** + * Create a campaign + * + * @function createCampaign + * @param {object} parameters - Additional parameters for the campaign to be created + * @param {string} parameters.name - The name of the campaign + * @param {string} [parameters.section='Products'] - The section of the index to use + * @param {string} [parameters.description] - The description of the campaign + * @param {string} [parameters.requestTagName] - Request tag name used to activate this campaign for, used only with `request_tag_value` + * @param {string} [parameters.requestTagValue] - Request tag value used to activate this campaign for, used only with `request_tag_name` + * @param {string} [parameters.startTime] - The start time of the campaign (ISO 8601 date-time) + * @param {string} [parameters.endTime] - The end time of the campaign (ISO 8601 date-time) + * @param {object[]} [parameters.refinedQueries] - A list of refined queries + * @param {object[]} [parameters.refinedFilters] - A list of refined filters + * @param {object[]} [parameters.refinedRecommendationContexts] - A list of refined recommendation contexts + * @param {object[]} [parameters.boostRules] - A list of boost rules + * @param {object[]} [parameters.blacklistRules] - A list of blacklist rules + * @param {object[]} [parameters.slotRules] - A list of slot rules + * @param {object[]} [parameters.contentRules] - A list of content rules + * @param {object[]} [parameters.filtersSlotRules] - A list of filters slot rules + * @param {object} [parameters.whitelistRule] - A whitelist rule + * @param {object} [parameters.variationSlicingRule] - A variation slicing rule + * @param {object} [parameters.metadataJson] - Metadata related to the campaign + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {Promise} + * @see https://docs.constructor.com/reference/v1-searchandising-create-campaign + * @example + * constructorio.searchandising.campaigns.createCampaign({ + * name: 'Spring Sale', + * section: 'Products', + * description: 'Seasonal promotion campaign', + * boostRules: [ + * { rule_type: 'boost', rule: { boost: 5, filters: { brand: ['Nike'] } } }, + * ], + * }); + */ + createCampaign(parameters = {}, networkParameters = {}) { + const queryParams = {}; + let requestUrl; + const { fetch } = this.options; + const controller = new AbortController(); + const { signal } = controller; + const headers = { + 'Content-Type': 'application/json', + }; + const { section, ...body } = parameters; + + if (section) { + queryParams.section = section; + } + + try { + requestUrl = createCampaignsUrl('campaigns', this.options, queryParams); + } catch (e) { + return Promise.reject(e); + } + + Object.assign(headers, helpers.combineCustomHeaders(this.options, networkParameters)); + + helpers.applyNetworkTimeout(this.options, networkParameters, controller); + + return fetch(requestUrl, { + method: 'POST', + body: JSON.stringify(helpers.toSnakeCaseKeys(body, false)), + headers: { + ...headers, + ...helpers.createAuthHeader(this.options), + }, + signal, + }).then((response) => { + if (response.ok) { + return response.json(); + } + + return helpers.throwHttpErrorFromResponse(new Error(), response); + }).then((json) => json); + } + + /** + * Update a campaign - only the fields present in the request will be updated + * + * @function updateCampaign + * @param {object} parameters - Additional parameters for the campaign to be updated + * @param {number} parameters.id - The ID of the campaign to be updated + * @param {string} [parameters.section='Products'] - The section of the index to use + * @param {string} [parameters.name] - The name of the campaign + * @param {string} [parameters.description] - The description of the campaign + * @param {string} [parameters.requestTagName] - Request tag name used to activate this campaign for, used only with `request_tag_value` + * @param {string} [parameters.requestTagValue] - Request tag value used to activate this campaign for, used only with `request_tag_name` + * @param {string} [parameters.startTime] - The start time of the campaign (ISO 8601 date-time) + * @param {string} [parameters.endTime] - The end time of the campaign (ISO 8601 date-time) + * @param {object[]} [parameters.refinedQueries] - A list of refined queries + * @param {object[]} [parameters.refinedFilters] - A list of refined filters + * @param {object[]} [parameters.refinedRecommendationContexts] - A list of refined recommendation contexts + * @param {object[]} [parameters.boostRules] - A list of boost rules + * @param {object[]} [parameters.blacklistRules] - A list of blacklist rules + * @param {object[]} [parameters.slotRules] - A list of slot rules + * @param {object[]} [parameters.contentRules] - A list of content rules + * @param {object[]} [parameters.filtersSlotRules] - A list of filters slot rules + * @param {object} [parameters.whitelistRule] - A whitelist rule + * @param {object} [parameters.variationSlicingRule] - A variation slicing rule + * @param {object} [parameters.metadataJson] - Metadata related to the campaign + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {Promise} + * @see https://docs.constructor.com/reference/v1-searchandising-update-campaign + * @example + * constructorio.searchandising.campaigns.updateCampaign({ + * id: 42, + * name: 'Spring Sale - Updated', + * description: 'Updated seasonal promotion campaign', + * }); + */ + updateCampaign(parameters = {}, networkParameters = {}) { + const queryParams = {}; + let requestUrl; + const { fetch } = this.options; + const controller = new AbortController(); + const { signal } = controller; + const headers = { + 'Content-Type': 'application/json', + }; + const { id, section, ...body } = parameters; + + if (section) { + queryParams.section = section; + } + + try { + requestUrl = createCampaignsUrl(`campaigns/${id}`, this.options, queryParams); + } catch (e) { + return Promise.reject(e); + } + + Object.assign(headers, helpers.combineCustomHeaders(this.options, networkParameters)); + + helpers.applyNetworkTimeout(this.options, networkParameters, controller); + + return fetch(requestUrl, { + method: 'PATCH', + body: JSON.stringify(helpers.toSnakeCaseKeys(body, false)), + headers: { + ...headers, + ...helpers.createAuthHeader(this.options), + }, + signal, + }).then((response) => { + if (response.ok) { + return response.json(); + } + + return helpers.throwHttpErrorFromResponse(new Error(), response); + }).then((json) => json); + } + + /** + * Delete a campaign given a specific id + * + * @function deleteCampaign + * @param {object} parameters - Additional parameters for the campaign to be deleted + * @param {number} parameters.id - The ID of the campaign to be deleted + * @param {string} [parameters.section='Products'] - The section of the index to use + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {Promise} + * @see https://docs.constructor.com/reference/v1-searchandising-delete-campaign + * @example + * constructorio.searchandising.campaigns.deleteCampaign({ + * id: 42, + * section: 'Products', + * }); + */ + deleteCampaign(parameters = {}, networkParameters = {}) { + const queryParams = {}; + let requestUrl; + const { fetch } = this.options; + const controller = new AbortController(); + const { signal } = controller; + const headers = { + 'Content-Type': 'application/json', + }; + const { id, section } = parameters; + + if (section) { + queryParams.section = section; + } + + try { + requestUrl = createCampaignsUrl(`campaigns/${id}`, this.options, queryParams); + } catch (e) { + return Promise.reject(e); + } + + Object.assign(headers, helpers.combineCustomHeaders(this.options, networkParameters)); + + helpers.applyNetworkTimeout(this.options, networkParameters, controller); + + return fetch(requestUrl, { + method: 'DELETE', + headers: { + ...headers, + ...helpers.createAuthHeader(this.options), + }, + signal, + }).then((response) => { + if (response.ok) { + return Promise.resolve(); + } + + return helpers.throwHttpErrorFromResponse(new Error(), response); + }); + } +} + +module.exports = Campaigns; diff --git a/src/modules/searchandising/index.js b/src/modules/searchandising/index.js new file mode 100644 index 00000000..d571640f --- /dev/null +++ b/src/modules/searchandising/index.js @@ -0,0 +1,18 @@ +/* eslint-disable max-len */ +const Campaigns = require('./campaigns'); + +/** + * Interface to searchandising related API calls + * + * @module searchandising + * @inner + * @returns {object} + */ +class Searchandising { + constructor(options) { + this.options = options || {}; + this.campaigns = new Campaigns(this.options); + } +} + +module.exports = Searchandising; diff --git a/src/types/constructorio.d.ts b/src/types/constructorio.d.ts index 0475d155..8ee95b31 100644 --- a/src/types/constructorio.d.ts +++ b/src/types/constructorio.d.ts @@ -6,6 +6,7 @@ import Tracker from './tracker'; import Catalog from './catalog'; import Tasks from './tasks'; import Quizzes from './quizzes'; +import Searchandising from './searchandising'; import { ConstructorClientOptions } from '.'; @@ -31,4 +32,6 @@ declare class ConstructorIO { tasks: Tasks; quizzes: Quizzes; + + searchandising: Searchandising; } diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 9c863457..c760ac93 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -6,6 +6,7 @@ export * from './recommendations'; export * from './search'; export * from './tasks'; export * from './tracker'; +export * from './searchandising'; export interface NetworkParameters extends Record { timeout?: number; diff --git a/src/types/searchandising.d.ts b/src/types/searchandising.d.ts new file mode 100644 index 00000000..7b0ccac4 --- /dev/null +++ b/src/types/searchandising.d.ts @@ -0,0 +1,306 @@ +import { ConstructorClientOptions, NetworkParameters } from '.'; + +export default Searchandising; + +export interface RetrieveCampaignsParameters { + section?: string; + id?: number | number[]; + refinedFilters?: Record; + numResultsPerPage?: number; + page?: number; + offset?: number; + refinedRecommendationContexts?: RefinedRecommendationContext; + refinedQueries?: string[]; +} + +export interface RefinedRecommendationContext { + pod_id?: string[]; + condition_type?: ('attribute' | 'item' | 'expression')[]; +} + +export interface RetrieveCampaignParameters { + id: number; + section?: string; +} + +/* fields shared by create and update campaign requests */ +export interface CampaignParametersBase { + section?: string; + name?: string; + description?: string; + requestTagName?: RequestTag; + requestTagValue?: string; + startTime?: string; + endTime?: string; + refinedQueries?: RefinedQuery[]; + refinedFilters?: RefinedFilter[]; + refinedRecommendationContexts?: RecommendationContext[]; + boostRules?: CampaignRuleInput[]; + blacklistRules?: CampaignRuleInput[]; + slotRules?: CampaignRuleInput[]; + contentRules?: CampaignRuleInput[]; + filtersSlotRules?: CampaignRuleInput[]; + whitelistRule?: CampaignRuleInput | null; + variationSlicingRule?: CampaignRuleInput | null; + metadataJson?: CampaignMetadata; +} + +export interface CreateCampaignParameters extends CampaignParametersBase { + name: string; +} + +export interface UpdateCampaignParameters extends CampaignParametersBase { + id: number; +} + +export interface DeleteCampaignParameters { + id: number; + section?: string; +} + +export interface Campaigns { + options: ConstructorClientOptions; + + retrieveCampaigns( + parameters?: RetrieveCampaignsParameters, + networkParameters?: NetworkParameters + ): Promise; + + retrieveCampaign( + parameters: RetrieveCampaignParameters, + networkParameters?: NetworkParameters + ): Promise; + + createCampaign( + parameters: CreateCampaignParameters, + networkParameters?: NetworkParameters + ): Promise; + + updateCampaign( + parameters: UpdateCampaignParameters, + networkParameters?: NetworkParameters + ): Promise; + + deleteCampaign( + parameters: DeleteCampaignParameters, + networkParameters?: NetworkParameters + ): Promise; +} + +declare class Searchandising { + constructor(options: ConstructorClientOptions); + + options: ConstructorClientOptions; + + campaigns: Campaigns; +} + +export type RequestTag = + | 'client_ip' + | 'client_version' + | 'geo_city' + | 'geo_country' + | 'geo_country_iso_code' + | 'geo_region' + | 'dt_weekday' + | 'dt_timeofday' + | 'user_segment' + | 'autogenerated_user_segment' + | 'dynamic_segment'; + +export interface RefinedQuery { + query: string; +} + +export interface RefinedFilter { + filter_name: string; + filter_value: string; +} + +export interface RecommendationContext { + pod_id: string; + condition: RecommendationContextCondition; +} + +export type RecommendationContextCondition = + | RecommendationContextItemCondition + | RecommendationContextAttributeCondition + | RecommendationContextExpressionCondition; + +export interface RecommendationContextItemCondition { + type: 'item'; + item_id?: string; +} + +export interface RecommendationContextAttributeCondition { + type: 'attribute'; + filter_name?: string; + filter_value?: string; +} + +export interface RecommendationContextExpressionCondition { + type: 'expression'; + expression: Record; +} + +export type FiltersObj = Record; + +export interface PositionRange { + start: number; + end: number; +} + +export interface BoostRuleParameters { + filters?: FiltersObj; + item_ids?: string[]; + boost: number; +} + +export interface BlacklistRuleParameters { + filters?: FiltersObj; + item_ids?: string[]; +} + +export interface SlotRuleParameters { + item_id: string; + position: number; + variation_slice?: Record; + fuzzy_match?: boolean; + labels?: Record; +} + +export interface ContentRuleParameters { + data: Record; +} + +export interface FiltersSlotRuleParameters { + position_ranges: PositionRange[]; + filters?: FiltersObj; + filter_expression?: Record | string; + labels?: Record; +} + +export interface WhitelistRuleParameters { + filters: FiltersObj; +} + +export interface VariationSlicingRuleParameters { + facet_names: string[]; + filter_expression?: Record | string; +} + +/* shared fields present on every campaign rule */ +export interface RuleObjBase { + id: number; + request_tag_name?: RequestTag; + request_tag_value?: string; + active?: boolean; + start_time?: string; + end_time?: string; + campaign_id?: number; + automatically_generated?: boolean; + created_at?: string; + updated_at?: string; +} + +export interface BoostRule extends RuleObjBase { + rule_type: 'boost'; + rule: BoostRuleParameters; +} + +export interface BlacklistRule extends RuleObjBase { + rule_type: 'blacklist'; + rule: BlacklistRuleParameters; +} + +export interface SlotRule extends RuleObjBase { + rule_type: 'slot'; + rule: SlotRuleParameters; + request_filters?: Record; +} + +export interface ContentRule extends RuleObjBase { + rule_type: 'content'; + rule: ContentRuleParameters; +} + +export interface FiltersSlotRule extends RuleObjBase { + rule_type: 'filters_slot'; + rule: FiltersSlotRuleParameters; +} + +export interface WhitelistRule extends RuleObjBase { + rule_type: 'whitelist'; + rule: WhitelistRuleParameters; +} + +export interface VariationSlicingRule extends RuleObjBase { + rule_type: 'variation_slicing'; + rule: VariationSlicingRuleParameters; +} + +export type RuleObj = + | BoostRule + | BlacklistRule + | SlotRule + | ContentRule + | FiltersSlotRule + | WhitelistRule + | VariationSlicingRule; + +/* + * Omit the server-assigned fields from each rule. + * `Omit` does not distribute over unions - applied directly to RuleObj it would merge + * all members into one object and decouple rule_type from rule. The `T extends unknown` + * conditional forces distribution so each member is omitted individually and the + * rule_type/rule discrimination is preserved. + */ +type OmitServerFields = T extends unknown + ? Omit + : never; + +/* rule definition supplied when creating or modifying a campaign - server-assigned fields are omitted */ +export type CampaignRuleInput = OmitServerFields; + +export interface CampaignMetadata { + goal?: string; + goal_description?: string; +} + +export interface Campaign extends Record { + id: number; + created_at: string; + updated_at: string; + name?: string; + description?: string; + request_tag_name?: RequestTag; + request_tag_value?: string; + start_time?: string; + end_time?: string; + refined_queries?: RefinedQuery[]; + refined_filters?: RefinedFilter[]; + refined_recommendation_contexts?: RecommendationContext[]; + boost_rules?: BoostRule[]; + blacklist_rules?: BlacklistRule[]; + slot_rules?: SlotRule[]; + content_rules?: ContentRule[]; + filters_slot_rules?: FiltersSlotRule[]; + whitelist_rule?: WhitelistRule; + variation_slicing_rule?: VariationSlicingRule; + metadata_json?: CampaignMetadata; +} + +/* campaigns results returned from server */ +export interface CampaignListGetResponse { + campaigns: Campaign[]; + total_count: number; +} + +/* single campaign result returned from server */ +export type CampaignGetResponse = Campaign; + +/* campaign result returned from server after creation */ +export type CampaignPostResponse = Campaign; + +/* campaign result returned from server after update */ +export type CampaignPatchResponse = Campaign; diff --git a/src/types/tests/searchandising.test-d.ts b/src/types/tests/searchandising.test-d.ts new file mode 100644 index 00000000..c669a545 --- /dev/null +++ b/src/types/tests/searchandising.test-d.ts @@ -0,0 +1,60 @@ +import { expectAssignable } from 'tsd'; +import { + CampaignListGetResponse, + Campaign, + RetrieveCampaignsParameters, + CreateCampaignParameters, +} from '../searchandising'; + +expectAssignable({ + section: 'Products', + id: [1, 2], + refinedFilters: { group_id: ['123', '456'] }, + numResultsPerPage: 50, + page: 1, + refinedRecommendationContexts: { + pod_id: ['pod-a'], + condition_type: ['item'], + }, + refinedQueries: ['shoes', 'boots'], +}); + +expectAssignable({ + name: 'Spring Sale', + section: 'Products', + description: 'Seasonal promotion campaign', + refinedQueries: [{ query: 'shoes' }], + boostRules: [ + { + rule_type: 'boost', + rule: { boost: 5, filters: { brand: ['Nike'] } }, + }, + ], +}); + +expectAssignable({ + id: 42, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + name: 'Spring Sale', + refined_queries: [{ query: 'shoes' }], + boost_rules: [ + { + id: 1, + rule_type: 'boost', + rule: { boost: 5, filters: { brand: ['Nike'] } }, + }, + ], +}); + +expectAssignable({ + campaigns: [ + { + id: 42, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + name: 'Spring Sale', + }, + ], + total_count: 1, +});