diff --git a/.github/workflows/run-tests-bundled.yml b/.github/workflows/run-tests-bundled.yml index 59424da0..5a8dbff7 100644 --- a/.github/workflows/run-tests-bundled.yml +++ b/.github/workflows/run-tests-bundled.yml @@ -26,4 +26,5 @@ jobs: env: TEST_REQUEST_API_KEY: ${{ secrets.TEST_REQUEST_API_KEY }} TEST_MEDIA_REQUEST_API_KEY: ${{ secrets.TEST_MEDIA_REQUEST_API_KEY }} + TEST_PIA_REQUEST_API_KEY: ${{ secrets.TEST_PIA_REQUEST_API_KEY }} SKIP_NETWORK_TIMEOUT_TESTS: true diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 34286c62..e3a29123 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -26,4 +26,5 @@ jobs: env: TEST_REQUEST_API_KEY: ${{ secrets.TEST_REQUEST_API_KEY }} TEST_MEDIA_REQUEST_API_KEY: ${{ secrets.TEST_MEDIA_REQUEST_API_KEY }} + TEST_PIA_REQUEST_API_KEY: ${{ secrets.TEST_PIA_REQUEST_API_KEY }} SKIP_NETWORK_TIMEOUT_TESTS: true diff --git a/spec/src/modules/pia.js b/spec/src/modules/pia.js new file mode 100644 index 00000000..970131b0 --- /dev/null +++ b/spec/src/modules/pia.js @@ -0,0 +1,292 @@ +/* eslint-disable no-unused-expressions, import/no-unresolved, func-names */ +const dotenv = require('dotenv'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const fs = require('fs'); +const helpers = require('../../mocha.helpers'); +const jsdom = require('../utils/jsdom-global'); +let ConstructorIO = require('../../../test/constructorio'); // eslint-disable-line import/extensions + +chai.use(chaiAsPromised); +chai.use(sinonChai); +dotenv.config(); + +const piaApiKey = process.env.TEST_PIA_REQUEST_API_KEY; +const clientVersion = 'cio-mocha'; +const bundled = process.env.BUNDLED === 'true'; +const skipNetworkTimeoutTests = process.env.SKIP_NETWORK_TIMEOUT_TESTS === 'true'; +const bundledDescriptionSuffix = bundled ? ' - Bundled' : ''; + +describe(`ConstructorIO - Pia${bundledDescriptionSuffix}`, () => { + const validItemId = '149100215'; + const validQuestion = 'What material is this made of?'; + const jsdomOptions = { url: 'http://localhost' }; + let fetchSpy; + let cleanup; + + if (bundled) { + jsdomOptions.src = fs.readFileSync(`./dist/constructorio-client-javascript-${process.env.PACKAGE_VERSION}.js`, 'utf-8'); + } + + beforeEach(() => { + cleanup = jsdom(jsdomOptions); + global.CLIENT_VERSION = clientVersion; + window.CLIENT_VERSION = clientVersion; + fetchSpy = sinon.spy(fetch); + + if (bundled) { + ConstructorIO = window.ConstructorioClient; + } + }); + + afterEach(() => { + delete global.CLIENT_VERSION; + delete window.CLIENT_VERSION; + cleanup(); + + fetchSpy = null; + }); + + describe('getSuggestedQuestions', () => { + it('Should return a result provided a valid apiKey and itemId', () => { + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + }); + + return pia.getSuggestedQuestions(validItemId).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(res).to.have.property('questions').to.be.an('array'); + expect(res.questions.length).to.be.greaterThan(0); + expect(res.questions[0]).to.have.property('value').to.be.a('string'); + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('i'); + expect(requestedUrlParams).to.have.property('s'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + expect(requestedUrlParams).to.have.property('item_id').to.equal(validItemId); + }); + }); + + it('Should return a result provided a valid apiKey, itemId and variationId', () => { + const variationId = 'variation-123'; + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + }); + + return pia.getSuggestedQuestions(validItemId, { variationId }).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('variation_id').to.equal(variationId); + }); + }); + + it('Should return a result provided a valid apiKey, itemId and numResults', () => { + const numResults = 2; + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + }); + + return pia.getSuggestedQuestions(validItemId, { numResults }).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(res).to.have.property('questions').to.be.an('array'); + expect(requestedUrlParams).to.have.property('num_results').to.equal(numResults.toString()); + }); + }); + + it('Should return a result provided a valid apiKey, itemId and user id', () => { + const userId = 'user-id'; + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + userId, + }); + + return pia.getSuggestedQuestions(validItemId).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('ui').to.equal(userId); + }); + }); + + it('Should return a result provided a valid apiKey, itemId and segments', () => { + const segments = ['foo', 'bar']; + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + segments, + }); + + return pia.getSuggestedQuestions(validItemId).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('us').to.deep.equal(segments); + }); + }); + + it('Should be rejected if no itemId is provided', () => { + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + }); + + return expect(pia.getSuggestedQuestions(null)).to.eventually.be.rejected; + }); + + it('Should be rejected if an invalid apiKey is provided', () => { + const { pia } = new ConstructorIO({ + apiKey: 'invalidKey', + fetch: fetchSpy, + }); + + return expect(pia.getSuggestedQuestions(validItemId)).to.eventually.be.rejected; + }); + + if (!skipNetworkTimeoutTests) { + it('Should be rejected when network request timeout is provided and reached', () => { + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + }); + + return expect(pia.getSuggestedQuestions(validItemId, {}, { timeout: 20 })).to.eventually.be.rejectedWith('This operation was aborted'); + }); + + it('Should be rejected when global network request timeout is provided and reached', () => { + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + networkParameters: { timeout: 20 }, + }); + + return expect(pia.getSuggestedQuestions(validItemId)).to.eventually.be.rejectedWith('This operation was aborted'); + }); + } + }); + + describe('getAnswerResults', () => { + it('Should return a result provided a valid apiKey, itemId and question', function () { + this.timeout(10000); + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + }); + + return pia.getAnswerResults(validItemId, validQuestion).then((res) => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(res).to.have.property('qna_result_id').to.be.a('string'); + expect(res).to.have.property('value').to.be.a('string'); + expect(fetchSpy).to.have.been.called; + expect(requestedUrlParams).to.have.property('key'); + expect(requestedUrlParams).to.have.property('i'); + expect(requestedUrlParams).to.have.property('s'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + expect(requestedUrlParams).to.have.property('item_id').to.equal(validItemId); + }); + }); + + it('Should return a result provided a valid apiKey, itemId, question and variationId', function () { + this.timeout(10000); + const variationId = 'variation-123'; + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + }); + + return pia.getAnswerResults(validItemId, validQuestion, { variationId }).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('variation_id').to.equal(variationId); + }); + }); + + it('Should return a result provided a valid apiKey, itemId, question and user id', function () { + this.timeout(10000); + const userId = 'user-id'; + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + userId, + }); + + return pia.getAnswerResults(validItemId, validQuestion).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('ui').to.equal(userId); + }); + }); + + it('Should return a result provided a valid apiKey, itemId, question and segments', function () { + this.timeout(10000); + const segments = ['foo', 'bar']; + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + segments, + }); + + return pia.getAnswerResults(validItemId, validQuestion).then(() => { + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + expect(requestedUrlParams).to.have.property('us').to.deep.equal(segments); + }); + }); + + it('Should be rejected if no itemId is provided', () => { + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + }); + + return expect(pia.getAnswerResults(null, validQuestion)).to.eventually.be.rejected; + }); + + it('Should be rejected if no question is provided', () => { + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + }); + + return expect(pia.getAnswerResults(validItemId, null)).to.eventually.be.rejected; + }); + + it('Should be rejected if an invalid apiKey is provided', () => { + const { pia } = new ConstructorIO({ + apiKey: 'invalidKey', + fetch: fetchSpy, + }); + + return expect(pia.getAnswerResults(validItemId, validQuestion)).to.eventually.be.rejected; + }); + + if (!skipNetworkTimeoutTests) { + it('Should be rejected when network request timeout is provided and reached', () => { + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + }); + + return expect(pia.getAnswerResults(validItemId, validQuestion, {}, { timeout: 20 })).to.eventually.be.rejectedWith('This operation was aborted'); + }); + + it('Should be rejected when global network request timeout is provided and reached', () => { + const { pia } = new ConstructorIO({ + apiKey: piaApiKey, + fetch: fetchSpy, + networkParameters: { timeout: 20 }, + }); + + return expect(pia.getAnswerResults(validItemId, validQuestion)).to.eventually.be.rejectedWith('This operation was aborted'); + }); + } + }); +}); diff --git a/src/constructorio.js b/src/constructorio.js index 8a469785..ab016a6c 100644 --- a/src/constructorio.js +++ b/src/constructorio.js @@ -12,6 +12,7 @@ const helpers = require('./utils/helpers'); const { default: packageVersion } = require('./version'); const Quizzes = require('./modules/quizzes'); const Agent = require('./modules/agent'); +const Pia = require('./modules/pia'); const Assistant = require('./modules/assistant'); // Compute package version string @@ -65,6 +66,7 @@ class ConstructorIO { * @property {object} tracker - Interface to {@link module:tracker} * @property {object} quizzes - Interface to {@link module:quizzes} * @property {object} agent - Interface to {@link module:agent} + * @property {object} pia - Interface to {@link module:pia} * @property {object} assistant - Interface to {@link module:assistant} @deprecated This property is deprecated and will be removed in a future version. Use the agent property instead. * @returns {class} */ @@ -149,6 +151,7 @@ class ConstructorIO { this.tracker = new Tracker(this.options); this.quizzes = new Quizzes(this.options); this.agent = new Agent(this.options); + this.pia = new Pia(this.options); this.assistant = new Assistant(this.options); // Dispatch initialization event diff --git a/src/modules/pia.js b/src/modules/pia.js new file mode 100644 index 00000000..7534e959 --- /dev/null +++ b/src/modules/pia.js @@ -0,0 +1,185 @@ +/* eslint-disable object-curly-newline, no-underscore-dangle */ +const EventDispatcher = require('../utils/event-dispatcher'); +const helpers = require('../utils/helpers'); + +// Create URL from supplied itemId and parameters +function createPiaUrl(itemId, parameters, options, questionPath) { + const { + apiKey, + clientId, + sessionId, + segments, + userId, + version, + agentServiceUrl, + assistantServiceUrl, + } = options; + const serviceUrl = agentServiceUrl || assistantServiceUrl; + let queryParams = { c: version }; + + queryParams.key = apiKey; + queryParams.i = clientId; + queryParams.s = sessionId; + + // Validate item id is provided + if (!itemId || typeof itemId !== 'string') { + throw new Error('itemId is a required parameter of type string'); + } + + queryParams.item_id = itemId; + + // Pull user segments from options + if (segments && segments.length) { + queryParams.us = segments; + } + + // Pull user id from options and ensure string + if (userId) { + queryParams.ui = String(userId); + } + + if (parameters) { + const { threadId, variationId, numResults } = parameters; + + if (threadId) { + queryParams.thread_id = threadId; + } + + if (variationId) { + queryParams.variation_id = variationId; + } + + if (!helpers.isNil(numResults)) { + queryParams.num_results = numResults; + } + } + + queryParams._dt = Date.now(); + queryParams = helpers.cleanParams(queryParams); + + const queryString = helpers.stringify(queryParams); + + return `${serviceUrl}/v1/item_questions${questionPath}?${queryString}`; +} + +/** + * Interface to Product Insights Agent (PIA) related API calls + * + * @module pia + * @inner + * @returns {object} + */ +class Pia { + constructor(options) { + this.options = options || {}; + this.eventDispatcher = new EventDispatcher(options.eventDispatcher); + } + + /** + * Retrieve suggested questions for an item + * + * @function getSuggestedQuestions + * @description Retrieve suggested questions for a product from Constructor.io API + * @param {string} itemId - The identifier of the item + * @param {object} [parameters] - Additional parameters to refine result set + * @param {string} [parameters.threadId] - Thread ID for conversation context (UUID) + * @param {string} [parameters.variationId] - Variation ID of the item + * @param {number} [parameters.numResults] - Number of suggested questions to return + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {Promise} + * @example + * constructorio.pia.getSuggestedQuestions('item-123', { + * variationId: 'variation-456', + * numResults: 3, + * }); + */ + getSuggestedQuestions(itemId, parameters, networkParameters = {}) { + let requestUrl; + const { fetch } = this.options; + let signal; + + if (typeof AbortController === 'function') { + const controller = new AbortController(); + + signal = controller && controller.signal; + + helpers.applyNetworkTimeout(this.options, networkParameters, controller); + } + + try { + requestUrl = createPiaUrl(itemId, parameters, this.options, ''); + } catch (e) { + return Promise.reject(e); + } + + return fetch(requestUrl, { signal }) + .then(helpers.convertResponseToJson) + .then((json) => { + if (json.questions) { + this.eventDispatcher.queue('pia.getSuggestedQuestions.completed', json); + + return json; + } + + throw new Error('getSuggestedQuestions response data is malformed'); + }); + } + + /** + * Retrieve an answer for a question about an item + * + * @function getAnswerResults + * @description Retrieve an answer to a product question from Constructor.io API + * @param {string} itemId - The identifier of the item + * @param {string} question - The question to ask about the item + * @param {object} [parameters] - Additional parameters to refine result set + * @param {string} [parameters.threadId] - Thread ID for conversation context (UUID) + * @param {string} [parameters.variationId] - Variation ID of the item + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {Promise} + * @example + * constructorio.pia.getAnswerResults('item-123', 'What material is this made of?', { + * threadId: '550e8400-e29b-41d4-a716-446655440000', + * }); + */ + getAnswerResults(itemId, question, parameters, networkParameters = {}) { + let requestUrl; + const { fetch } = this.options; + let signal; + + if (typeof AbortController === 'function') { + const controller = new AbortController(); + + signal = controller && controller.signal; + + helpers.applyNetworkTimeout(this.options, networkParameters, controller); + } + + if (!question || typeof question !== 'string') { + return Promise.reject(new Error('question is a required parameter of type string')); + } + + try { + const encodedQuestion = helpers.encodeURIComponentRFC3986(helpers.trimNonBreakingSpaces(question)); + requestUrl = createPiaUrl(itemId, parameters, this.options, `/${encodedQuestion}/answer`); + } catch (e) { + return Promise.reject(e); + } + + return fetch(requestUrl, { signal }) + .then(helpers.convertResponseToJson) + .then((json) => { + if (json.qna_result_id) { + this.eventDispatcher.queue('pia.getAnswerResults.completed', json); + + return json; + } + + throw new Error('getAnswerResults response data is malformed'); + }); + } +} + +module.exports = Pia; diff --git a/src/types/constructorio.d.ts b/src/types/constructorio.d.ts index f68df4af..a7f689f2 100644 --- a/src/types/constructorio.d.ts +++ b/src/types/constructorio.d.ts @@ -4,6 +4,7 @@ import Autocomplete from './autocomplete'; import Recommendations from './recommendations'; import Quizzes from './quizzes'; import Agent from './agent'; +import Pia from './pia'; import Assistant from './assistant'; import Tracker from './tracker'; import { ConstructorClientOptions } from '.'; @@ -27,6 +28,8 @@ declare class ConstructorIO { agent: Agent; + pia: Pia; + assistant: Assistant; tracker: Tracker; @@ -35,5 +38,5 @@ declare class ConstructorIO { } declare namespace ConstructorIO { - export { Search, Browse, Autocomplete, Recommendations, Quizzes, Tracker, Agent, Assistant }; + export { Search, Browse, Autocomplete, Recommendations, Quizzes, Tracker, Agent, Pia, Assistant }; } diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 6ba5fc57..8bfc8969 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -7,6 +7,7 @@ export * from './search'; export * from './autocomplete'; export * from './quizzes'; export * from './agent'; +export * from './pia'; export * from './recommendations'; export * from './browse'; export * from './tracker'; diff --git a/src/types/pia.d.ts b/src/types/pia.d.ts new file mode 100644 index 00000000..f3f7001f --- /dev/null +++ b/src/types/pia.d.ts @@ -0,0 +1,60 @@ +import { + ConstructorClientOptions, + NetworkParameters, + Item, +} from '.'; + +export default Pia; + +export interface PiaQuestion { + value: string; +} + +export interface PiaSuggestedQuestionsParameters { + threadId?: string; + variationId?: string; + numResults?: number; +} + +export interface PiaAnswerResultsParameters { + threadId?: string; + variationId?: string; +} + +export interface PiaSuggestedQuestionsResponse { + questions: Array; +} + +export interface PiaAnswerItemResults { + request?: Record; + response: { + results: Array; + }; +} + +export interface PiaAnswerResultsResponse { + qna_result_id: string; + value: string; + item_results?: PiaAnswerItemResults; + follow_up_questions?: Array; + thread_id?: string; +} + +declare class Pia { + constructor(options: ConstructorClientOptions); + + options: ConstructorClientOptions; + + getSuggestedQuestions( + itemId: string, + parameters?: PiaSuggestedQuestionsParameters, + networkParameters?: NetworkParameters, + ): Promise; + + getAnswerResults( + itemId: string, + question: string, + parameters?: PiaAnswerResultsParameters, + networkParameters?: NetworkParameters, + ): Promise; +}