From 7ec50fde997aa61cd534dbbcc64631bfc02b3044 Mon Sep 17 00:00:00 2001 From: Vlad Saitov Date: Tue, 21 Oct 2025 19:23:50 +0500 Subject: [PATCH 1/4] [NO-REF] - add quiz trigger tracker events --- spec/src/modules/tracker.js | 842 ++++++++++++++++++++++++++++++++++++ src/modules/tracker.js | 181 ++++++++ src/types/tracker.d.ts | 22 + 3 files changed, 1045 insertions(+) diff --git a/spec/src/modules/tracker.js b/spec/src/modules/tracker.js index fce802ad..dda75f38 100644 --- a/spec/src/modules/tracker.js +++ b/spec/src/modules/tracker.js @@ -8490,6 +8490,848 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { }); }); + describe('trackQuizTriggerShow', () => { + const requiredParameters = { + quizId: 'coffee-quiz', + searchQuery: 'coff', + matchedFacet: 'coffee_facet', + matchedQuery: 'nescafe', + }; + const optionalParameters = { + section: 'Products', + }; + + it.only('Backwards Compatibility - Should respond with a valid response when snake cased parameters are provided', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + ...requestQueueOptions, + }); + const snakeCaseParameters = { + quiz_id: 'coffee-quiz', + search_query: 'nescafe', + matched_facet: 'coffee_facet', + matched_query: 'nescafe', + }; + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('key'); + expect(requestParams).to.have.property('i'); + expect(requestParams).to.have.property('s'); + expect(requestParams).to.have.property('c').to.equal(clientVersion); + expect(requestParams).to.have.property('_dt'); + validateOriginReferrer(requestParams); + expect(requestParams).to.have.property('quiz_id').to.equal(snakeCaseParameters.quiz_id); + expect(requestParams).to.have.property('search_query').to.equal(snakeCaseParameters.search_query); + expect(requestParams).to.have.property('matched_facet').to.equal(snakeCaseParameters.matched_facet); + expect(requestParams).to.have.property('matched_query').to.equal(snakeCaseParameters.matched_query); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerShow(snakeCaseParameters)).to.equal(true); + }); + + it('Should respond with a valid response when required parameters are provided', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('key'); + expect(requestParams).to.have.property('i'); + expect(requestParams).to.have.property('s'); + expect(requestParams).to.have.property('c').to.equal(clientVersion); + expect(requestParams).to.have.property('_dt'); + validateOriginReferrer(requestParams); + expect(requestParams).to.have.property('quiz_id').to.equal(requiredParameters.quizId); + expect(requestParams).to.have.property('search_query').to.equal(requiredParameters.searchQuery); + expect(requestParams).to.have.property('matched_facet').to.equal(requiredParameters.matchedFacet); + expect(requestParams).to.have.property('matched_query').to.equal(requiredParameters.matchedQuery); + expect(requestParams).to.have.property('section').to.equal(optionalParameters.section); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerShow({ ...requiredParameters, ...optionalParameters })).to.equal(true); + }); + + it('Should respond with a valid response when required parameters and segments are provided', (done) => { + const segments = ['foo', 'bar']; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + segments, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('us').to.deep.equal(segments); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerShow(requiredParameters)).to.equal(true); + }); + + it('Should respond with a valid response when required parameters and userId are provided', (done) => { + const userId = 'user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('ui').to.equal(userId); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerShow(requiredParameters)).to.equal(true); + }); + + it('Should respond with a valid response when required parameters and testCells are provided', (done) => { + const testCells = { foo: 'bar' }; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + testCells, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property(`ef-${Object.keys(testCells)[0]}`).to.equal(Object.values(testCells)[0]); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerShow(requiredParameters)).to.equal(true); + }); + + it('Should respond with a valid response when required and optional parameters are provided', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('search_query').to.equal(requiredParameters.searchQuery); + expect(requestParams).to.have.property('matched_facet').to.equal(requiredParameters.matchedFacet); + expect(requestParams).to.have.property('matched_query').to.equal(requiredParameters.matchedQuery); + expect(requestParams).to.have.property('section').to.equal(optionalParameters.section); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerShow(Object.assign(requiredParameters, optionalParameters))) + .to.equal(true); + }); + + it('Should throw an error when invalid quizId is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerShow({ ...requiredParameters, quizId: 1 })).to.be.an('error'); + }); + + it('Should throw an error when invalid searchQuery is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerShow({ ...requiredParameters, searchQuery: null })).to.be.an('error'); + }); + + it('Should throw an error when invalid matchedFacet is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerShow({ ...requiredParameters, matchedFacet: 1 })).to.be.an('error'); + }); + + it('Should throw an error when invalid matchedQuery is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerShow({ ...requiredParameters, matchedQuery: 1 })).to.be.an('error'); + }); + + it('Should throw an error when invalid section is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerShow({ ...requiredParameters, section: 1 })).to.be.an('error'); + }); + + it('Should throw an error when no quizId is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + const { quizId: _, ...rest } = requiredParameters; + expect(tracker.trackQuizTriggerShow(rest)).to.be.an('error'); + }); + + it('Should throw an error when no searchQuery is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + const { searchQuery: _, ...rest } = requiredParameters; + expect(tracker.trackQuizTriggerShow(rest)).to.be.an('error'); + }); + + it('Should throw an error when either matchedFacet or matchedQuery is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + const { matchedFacet: _, matchedQuery: __, ...rest } = requiredParameters; + expect(tracker.trackQuizTriggerShow(rest)).to.be.an('error'); + }); + + it('Should throw an error when no url is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + const { url: _, ...rest } = requiredParameters; + expect(tracker.trackQuizTriggerShow(rest)).to.be.an('error'); + }); + + it('Should throw an error when invalid parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerShow([])).to.be.an('error'); + }); + + it('Should throw an error when no parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerShow()).to.be.an('error'); + }); + + it('Should send along origin_referrer query param if sendReferrerWithTrackingEvents is true', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + sendReferrerWithTrackingEvents: true, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + validateOriginReferrer(requestParams); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerShow(requiredParameters)).to.equal(true); + }); + + it('Should not send along origin_referrer query param if sendReferrerWithTrackingEvents is false', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + sendReferrerWithTrackingEvents: false, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.not.have.property('origin_referrer'); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerShow(requiredParameters)).to.equal(true); + }); + + if (!skipNetworkTimeoutTests) { + it('Should be rejected when network request timeout is provided and reached', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + ...requestQueueOptions, + }); + + tracker.on('error', ({ message }) => { + expect(message).to.equal(timeoutRejectionMessage); + done(); + }); + + expect(tracker.trackQuizTriggerShow(requiredParameters, { timeout: 10 })).to.equal(true); + }); + + it('Should be rejected when global network request timeout is provided and reached', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + networkParameters: { + timeout: 20, + }, + ...requestQueueOptions, + }); + + tracker.on('error', ({ message }) => { + expect(message).to.equal(timeoutRejectionMessage); + done(); + }); + + expect(tracker.trackQuizTriggerShow(requiredParameters)).to.equal(true); + }); + } + + it('Should properly encode query parameters', (done) => { + const specialCharacters = '+[]&'; + const userId = `user-id ${specialCharacters}`; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('ui').to.equal(userId); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerShow(requiredParameters)).to.equal(true); + }); + + it('Should properly transform non-breaking spaces in parameters', (done) => { + const breakingSpaces = '   '; + const userId = `user-id ${breakingSpaces} user-id`; + const userIdExpected = 'user-id user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('ui').to.equal(userIdExpected); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerShow(requiredParameters)).to.equal(true); + }); + + it('Should truncate url param to 2048 characters max', (done) => { + const longUrl = createLongUrl(3000); + const truncatedUrl = longUrl.slice(0, 2048); + + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', () => { + const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams.url).to.equal(truncatedUrl); + + done(); + }); + + const parameters = { + ...requiredParameters, + url: longUrl, + }; + + expect(tracker.trackQuizTriggerShow(parameters)).to.equal(true); + }); + }); + + describe('trackQuizTriggerClick', () => { + const requiredParameters = { + quizId: 'coffee-quiz', + searchQuery: 'coff', + matchedFacet: 'coffee_facet', + matchedQuery: 'nescafe', + }; + const optionalParameters = { + section: 'Products', + }; + + it.only('Backwards Compatibility - Should respond with a valid response when snake cased parameters are provided', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + ...requestQueueOptions, + }); + const snakeCaseParameters = { + quiz_id: 'coffee-quiz', + search_query: 'nescafe', + matched_facet: 'coffee_facet', + matched_query: 'nescafe', + }; + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('key'); + expect(requestParams).to.have.property('i'); + expect(requestParams).to.have.property('s'); + expect(requestParams).to.have.property('c').to.equal(clientVersion); + expect(requestParams).to.have.property('_dt'); + validateOriginReferrer(requestParams); + expect(requestParams).to.have.property('quiz_id').to.equal(snakeCaseParameters.quiz_id); + expect(requestParams).to.have.property('search_query').to.equal(snakeCaseParameters.search_query); + expect(requestParams).to.have.property('matched_facet').to.equal(snakeCaseParameters.matched_facet); + expect(requestParams).to.have.property('matched_query').to.equal(snakeCaseParameters.matched_query); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerClick(snakeCaseParameters)).to.equal(true); + }); + + it('Should respond with a valid response when required parameters are provided', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('key'); + expect(requestParams).to.have.property('i'); + expect(requestParams).to.have.property('s'); + expect(requestParams).to.have.property('c').to.equal(clientVersion); + expect(requestParams).to.have.property('_dt'); + validateOriginReferrer(requestParams); + expect(requestParams).to.have.property('quiz_id').to.equal(requiredParameters.quizId); + expect(requestParams).to.have.property('search_query').to.equal(requiredParameters.searchQuery); + expect(requestParams).to.have.property('matched_facet').to.equal(requiredParameters.matchedFacet); + expect(requestParams).to.have.property('matched_query').to.equal(requiredParameters.matchedQuery); + expect(requestParams).to.have.property('section').to.equal(optionalParameters.section); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerClick({ ...requiredParameters, ...optionalParameters })).to.equal(true); + }); + + it('Should respond with a valid response when required parameters and segments are provided', (done) => { + const segments = ['foo', 'bar']; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + segments, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('us').to.deep.equal(segments); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerClick(requiredParameters)).to.equal(true); + }); + + it('Should respond with a valid response when required parameters and userId are provided', (done) => { + const userId = 'user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('ui').to.equal(userId); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerClick(requiredParameters)).to.equal(true); + }); + + it('Should respond with a valid response when required parameters and testCells are provided', (done) => { + const testCells = { foo: 'bar' }; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + testCells, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property(`ef-${Object.keys(testCells)[0]}`).to.equal(Object.values(testCells)[0]); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerClick(requiredParameters)).to.equal(true); + }); + + it('Should respond with a valid response when required and optional parameters are provided', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('search_query').to.equal(requiredParameters.searchQuery); + expect(requestParams).to.have.property('matched_facet').to.equal(requiredParameters.matchedFacet); + expect(requestParams).to.have.property('matched_query').to.equal(requiredParameters.matchedQuery); + expect(requestParams).to.have.property('section').to.equal(optionalParameters.section); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerClick(Object.assign(requiredParameters, optionalParameters))) + .to.equal(true); + }); + + it('Should throw an error when invalid quizId is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerClick({ ...requiredParameters, quizId: 1 })).to.be.an('error'); + }); + + it('Should throw an error when invalid searchQuery is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerClick({ ...requiredParameters, searchQuery: null })).to.be.an('error'); + }); + + it('Should throw an error when invalid matchedFacet is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerClick({ ...requiredParameters, matchedFacet: 1 })).to.be.an('error'); + }); + + it('Should throw an error when invalid matchedQuery is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerClick({ ...requiredParameters, matchedQuery: 1 })).to.be.an('error'); + }); + + it('Should throw an error when invalid section is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerClick({ ...requiredParameters, section: 1 })).to.be.an('error'); + }); + + it('Should throw an error when no quizId is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + const { quizId: _, ...rest } = requiredParameters; + expect(tracker.trackQuizTriggerClick(rest)).to.be.an('error'); + }); + + it('Should throw an error when no searchQuery is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + const { searchQuery: _, ...rest } = requiredParameters; + expect(tracker.trackQuizTriggerClick(rest)).to.be.an('error'); + }); + + it('Should throw an error when either matchedFacet or matchedQuery is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + const { matchedFacet: _, matchedQuery: __, ...rest } = requiredParameters; + expect(tracker.trackQuizTriggerClick(rest)).to.be.an('error'); + }); + + it('Should throw an error when no url is provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + const { url: _, ...rest } = requiredParameters; + expect(tracker.trackQuizTriggerClick(rest)).to.be.an('error'); + }); + + it('Should throw an error when invalid parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerClick([])).to.be.an('error'); + }); + + it('Should throw an error when no parameters are provided', () => { + const { tracker } = new ConstructorIO({ apiKey: testApiKey }); + + expect(tracker.trackQuizTriggerClick()).to.be.an('error'); + }); + + it('Should send along origin_referrer query param if sendReferrerWithTrackingEvents is true', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + sendReferrerWithTrackingEvents: true, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + validateOriginReferrer(requestParams); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerClick(requiredParameters)).to.equal(true); + }); + + it('Should not send along origin_referrer query param if sendReferrerWithTrackingEvents is false', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + sendReferrerWithTrackingEvents: false, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.not.have.property('origin_referrer'); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerClick(requiredParameters)).to.equal(true); + }); + + if (!skipNetworkTimeoutTests) { + it('Should be rejected when network request timeout is provided and reached', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + ...requestQueueOptions, + }); + + tracker.on('error', ({ message }) => { + expect(message).to.equal(timeoutRejectionMessage); + done(); + }); + + expect(tracker.trackQuizTriggerClick(requiredParameters, { timeout: 10 })).to.equal(true); + }); + + it('Should be rejected when global network request timeout is provided and reached', (done) => { + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + networkParameters: { + timeout: 20, + }, + ...requestQueueOptions, + }); + + tracker.on('error', ({ message }) => { + expect(message).to.equal(timeoutRejectionMessage); + done(); + }); + + expect(tracker.trackQuizTriggerClick(requiredParameters)).to.equal(true); + }); + } + + it('Should properly encode query parameters', (done) => { + const specialCharacters = '+[]&'; + const userId = `user-id ${specialCharacters}`; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('ui').to.equal(userId); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerClick(requiredParameters)).to.equal(true); + }); + + it('Should properly transform non-breaking spaces in parameters', (done) => { + const breakingSpaces = '   '; + const userId = `user-id ${breakingSpaces} user-id`; + const userIdExpected = 'user-id user-id'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + userId, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', (responseParams) => { + const requestParams = helpers.extractUrlParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams).to.have.property('ui').to.equal(userIdExpected); + + // Response + expect(responseParams).to.have.property('method').to.equal('POST'); + expect(responseParams).to.have.property('message').to.equal('ok'); + + done(); + }); + + expect(tracker.trackQuizTriggerClick(requiredParameters)).to.equal(true); + }); + + it('Should truncate url param to 2048 characters max', (done) => { + const longUrl = createLongUrl(3000); + const truncatedUrl = longUrl.slice(0, 2048); + + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + ...requestQueueOptions, + }); + + tracker.on('success', () => { + const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); + + // Request + expect(fetchSpy).to.have.been.called; + expect(requestParams.url).to.equal(truncatedUrl); + + done(); + }); + + const parameters = { + ...requiredParameters, + url: longUrl, + }; + + expect(tracker.trackQuizTriggerClick(parameters)).to.equal(true); + }); + }); + describe('on', () => { it('Should throw an error when providing an invalid messageType parameter', () => { const { tracker } = new ConstructorIO({ apiKey: testApiKey }); diff --git a/src/modules/tracker.js b/src/modules/tracker.js index 702e3a05..65246fbe 100644 --- a/src/modules/tracker.js +++ b/src/modules/tracker.js @@ -2430,6 +2430,187 @@ class Tracker { return new Error('parameters are required of type object'); } + /** + * Send quiz trigger show event to API + * + * @function trackQuizTriggerShow + * @param {object} parameters - Additional parameters to be sent with request + * @param {string} parameters.quizId - Quiz identifier + * @param {string} parameters.matchedFacet - Matched facet for this quiz + * @param {string} parameters.matchedQuery - Matched query for this quiz + * @param {string} parameters.searchQuery - Typed search query for this event to be triggered + * @param {string} [parameters.section='Products'] - Index section + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {(true|Error)} + * @description User typed a search query and it caused a quiz trigger to pop up + * @example + * constructorio.tracker.trackQuizTriggerShow( + * { + * quizId: 'coffee-quiz', + * matchedFacet: 'coffee_facet', + * searchQuery: 'coff', + * url: 'www.example.com', + * }, + * ); + */ + trackQuizTriggerShow(parameters, networkParameters = {}) { + // Ensure parameters are provided (required) + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { + const requestPath = `${this.options.serviceUrl}/v2/behavioral_action/quiz_trigger_show?`; + const { + quiz_id, + quizId = quiz_id, + section = 'Products', + matched_facet, + matchedFacet = matched_facet, + search_query, + searchQuery = search_query, + matched_query, + matchedQuery = matched_query, + } = parameters; + const queryParams = {}; + const bodyParams = {}; + + if (typeof quizId !== 'string') { + return new Error('"quizId" is a required parameter of type string'); + } + + if (typeof searchQuery !== 'string') { + return new Error('"searchQuery" is a required parameter of type string'); + } + + if (typeof matchedFacet !== 'string' && typeof matchedQuery !== 'string') { + return new Error('"Either matchedFacet or matchedQuery" is a required parameter of type string'); + } + + if (matchedFacet && typeof matchedFacet !== 'string') { + return new Error('If matchedFacet is present, it has to be a string'); + } + + if (matchedQuery && typeof matchedQuery !== 'string') { + return new Error('If matchedQuery is present, it has to be a string'); + } + + bodyParams.quiz_id = quizId; + bodyParams.search_query = searchQuery; + bodyParams.matched_facet = matchedFacet; + bodyParams.matched_query = matchedQuery; + + if (!helpers.isNil(section)) { + if (typeof section !== 'string') { + return new Error('"section" must be a string'); + } + queryParams.section = section; + bodyParams.section = section; + } + + const requestURL = `${requestPath}${applyParamsAsString(queryParams, this.options)}`; + const requestMethod = 'POST'; + const requestBody = applyParams(bodyParams, { ...this.options, requestMethod }); + + this.requests.queue( + requestURL, + requestMethod, + requestBody, + networkParameters, + ); + this.requests.send(); + + return true; + } + + this.requests.send(); + + return new Error('parameters are required of type object'); + } + + /** + * Send quiz trigger click event to API + * + * @function trackQuizTriggerClick + * @param {object} parameters - Additional parameters to be sent with request + * @param {string} parameters.quizId - Quiz identifier + * @param {string} parameters.matchedFacet - Matched facet for this quiz + * @param {string} parameters.matchedQuery - Matched query for this quiz + * @param {string} parameters.searchQuery - Typed search query for this event to be triggered + * @param {string} [parameters.section='Products'] - Index section + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {(true|Error)} + * @description User typed a search query and it caused a quiz trigger to pop up + * @example + * constructorio.tracker.trackQuizTriggerClick( + * { + * quizId: 'coffee-quiz', + * matchedFacet: 'coffee_facet', + * searchQuery: 'coff', + * url: 'www.example.com', + * }, + * ); + */ + trackQuizTriggerClick(parameters, networkParameters = {}) { + // Ensure parameters are provided (required) + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { + const requestPath = `${this.options.serviceUrl}/v2/behavioral_action/quiz_trigger_click?`; + const { + quiz_id, + quizId = quiz_id, + section = 'Products', + matched_facet, + matchedFacet = matched_facet, + search_query, + searchQuery = search_query, + matched_query, + matchedQuery = matched_query, + } = parameters; + const queryParams = {}; + const bodyParams = {}; + + if (typeof quizId !== 'string') { + return new Error('"quizId" is a required parameter of type string'); + } + + if (typeof searchQuery !== 'string') { + return new Error('"searchQuery" is a required parameter of type string'); + } + + if (typeof matchedFacet !== 'string' || typeof matchedQuery !== 'string') { + return new Error('"Either matchedFacet or matchedQuery" is a required parameter of type string'); + } + + bodyParams.quiz_id = quizId; + bodyParams.matched_facet = matchedFacet; + bodyParams.matchedQuery = matchedQuery; + + if (!helpers.isNil(section)) { + if (typeof section !== 'string') { + return new Error('"section" must be a string'); + } + queryParams.section = section; + bodyParams.section = section; + } + + const requestURL = `${requestPath}${applyParamsAsString(queryParams, this.options)}`; + const requestMethod = 'POST'; + const requestBody = applyParams(bodyParams, { ...this.options, requestMethod }); + + this.requests.queue( + requestURL, + requestMethod, + requestBody, + networkParameters, + ); + this.requests.send(); + + return true; + } + + this.requests.send(); + + return new Error('parameters are required of type object'); + } + /** * Send ASA request submitted event * diff --git a/src/types/tracker.d.ts b/src/types/tracker.d.ts index 7f2d16e3..1a420435 100644 --- a/src/types/tracker.d.ts +++ b/src/types/tracker.d.ts @@ -270,6 +270,28 @@ declare class Tracker { networkParameters?: NetworkParameters ): true | Error; + trackQuizTriggerShow( + parameters: { + quizId: string; + searchQuery: string; + section?: string; + matchedFacet?: string; + matchedQuery?: string; + }, + networkParameters?: NetworkParameters + ): true | Error; + + trackQuizTriggerClick( + parameters: { + quizId: string; + searchQuery: string; + section?: string; + matchedFacet?: string; + matchedQuery?: string; + }, + networkParameters?: NetworkParameters + ): true | Error; + trackAgentSubmit( parameters: { intent: string; From 7478b7505eae883d7ad8487a52c71e9a6c73e84e Mon Sep 17 00:00:00 2001 From: Vlad Saitov Date: Wed, 22 Oct 2025 15:43:09 +0500 Subject: [PATCH 2/4] Remove only --- spec/src/modules/tracker.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/src/modules/tracker.js b/spec/src/modules/tracker.js index dda75f38..3968978c 100644 --- a/spec/src/modules/tracker.js +++ b/spec/src/modules/tracker.js @@ -8501,7 +8501,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { section: 'Products', }; - it.only('Backwards Compatibility - Should respond with a valid response when snake cased parameters are provided', (done) => { + it('Backwards Compatibility - Should respond with a valid response when snake cased parameters are provided', (done) => { const { tracker } = new ConstructorIO({ apiKey: testApiKey, fetch: fetchSpy, @@ -8922,7 +8922,7 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { section: 'Products', }; - it.only('Backwards Compatibility - Should respond with a valid response when snake cased parameters are provided', (done) => { + it('Backwards Compatibility - Should respond with a valid response when snake cased parameters are provided', (done) => { const { tracker } = new ConstructorIO({ apiKey: testApiKey, fetch: fetchSpy, From 99ff9b30a6ccb96e79430a9c78232f370fcf45fa Mon Sep 17 00:00:00 2001 From: Vlad Saitov Date: Thu, 23 Oct 2025 19:31:19 +0500 Subject: [PATCH 3/4] Fix tests --- spec/src/modules/tracker.js | 68 ------------------------------------- src/modules/tracker.js | 3 +- 2 files changed, 2 insertions(+), 69 deletions(-) diff --git a/spec/src/modules/tracker.js b/spec/src/modules/tracker.js index 3968978c..dee82e1c 100644 --- a/spec/src/modules/tracker.js +++ b/spec/src/modules/tracker.js @@ -8726,12 +8726,6 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { expect(tracker.trackQuizTriggerShow(rest)).to.be.an('error'); }); - it('Should throw an error when no url is provided', () => { - const { tracker } = new ConstructorIO({ apiKey: testApiKey }); - const { url: _, ...rest } = requiredParameters; - expect(tracker.trackQuizTriggerShow(rest)).to.be.an('error'); - }); - it('Should throw an error when invalid parameters are provided', () => { const { tracker } = new ConstructorIO({ apiKey: testApiKey }); @@ -8881,34 +8875,6 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { expect(tracker.trackQuizTriggerShow(requiredParameters)).to.equal(true); }); - - it('Should truncate url param to 2048 characters max', (done) => { - const longUrl = createLongUrl(3000); - const truncatedUrl = longUrl.slice(0, 2048); - - const { tracker } = new ConstructorIO({ - apiKey: testApiKey, - fetch: fetchSpy, - ...requestQueueOptions, - }); - - tracker.on('success', () => { - const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); - - // Request - expect(fetchSpy).to.have.been.called; - expect(requestParams.url).to.equal(truncatedUrl); - - done(); - }); - - const parameters = { - ...requiredParameters, - url: longUrl, - }; - - expect(tracker.trackQuizTriggerShow(parameters)).to.equal(true); - }); }); describe('trackQuizTriggerClick', () => { @@ -9147,12 +9113,6 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { expect(tracker.trackQuizTriggerClick(rest)).to.be.an('error'); }); - it('Should throw an error when no url is provided', () => { - const { tracker } = new ConstructorIO({ apiKey: testApiKey }); - const { url: _, ...rest } = requiredParameters; - expect(tracker.trackQuizTriggerClick(rest)).to.be.an('error'); - }); - it('Should throw an error when invalid parameters are provided', () => { const { tracker } = new ConstructorIO({ apiKey: testApiKey }); @@ -9302,34 +9262,6 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { expect(tracker.trackQuizTriggerClick(requiredParameters)).to.equal(true); }); - - it('Should truncate url param to 2048 characters max', (done) => { - const longUrl = createLongUrl(3000); - const truncatedUrl = longUrl.slice(0, 2048); - - const { tracker } = new ConstructorIO({ - apiKey: testApiKey, - fetch: fetchSpy, - ...requestQueueOptions, - }); - - tracker.on('success', () => { - const requestParams = helpers.extractBodyParamsFromFetch(fetchSpy); - - // Request - expect(fetchSpy).to.have.been.called; - expect(requestParams.url).to.equal(truncatedUrl); - - done(); - }); - - const parameters = { - ...requiredParameters, - url: longUrl, - }; - - expect(tracker.trackQuizTriggerClick(parameters)).to.equal(true); - }); }); describe('on', () => { diff --git a/src/modules/tracker.js b/src/modules/tracker.js index 65246fbe..5c43040a 100644 --- a/src/modules/tracker.js +++ b/src/modules/tracker.js @@ -2581,7 +2581,8 @@ class Tracker { bodyParams.quiz_id = quizId; bodyParams.matched_facet = matchedFacet; - bodyParams.matchedQuery = matchedQuery; + bodyParams.matched_query = matchedQuery; + bodyParams.search_query = searchQuery; if (!helpers.isNil(section)) { if (typeof section !== 'string') { From 2cfbb921081182569afbafd853a7c111ef14deef Mon Sep 17 00:00:00 2001 From: Vlad Saitov Date: Mon, 27 Oct 2025 20:02:44 +0500 Subject: [PATCH 4/4] Fix description and some other stuff --- src/modules/tracker.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/modules/tracker.js b/src/modules/tracker.js index 5c43040a..b74b75c7 100644 --- a/src/modules/tracker.js +++ b/src/modules/tracker.js @@ -2449,8 +2449,8 @@ class Tracker { * { * quizId: 'coffee-quiz', * matchedFacet: 'coffee_facet', + * matchedQuery: 'nescafe', * searchQuery: 'coff', - * url: 'www.example.com', * }, * ); */ @@ -2538,14 +2538,14 @@ class Tracker { * @param {object} [networkParameters] - Parameters relevant to the network request * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) * @returns {(true|Error)} - * @description User typed a search query and it caused a quiz trigger to pop up + * @description Triggered whenever user clicks on a quiz trigger popup * @example * constructorio.tracker.trackQuizTriggerClick( * { * quizId: 'coffee-quiz', * matchedFacet: 'coffee_facet', + * matchedQuery: 'nescafe', * searchQuery: 'coff', - * url: 'www.example.com', * }, * ); */ @@ -2575,10 +2575,18 @@ class Tracker { return new Error('"searchQuery" is a required parameter of type string'); } - if (typeof matchedFacet !== 'string' || typeof matchedQuery !== 'string') { + if (typeof matchedFacet !== 'string' && typeof matchedQuery !== 'string') { return new Error('"Either matchedFacet or matchedQuery" is a required parameter of type string'); } + if (matchedFacet && typeof matchedFacet !== 'string') { + return new Error('If matchedFacet is present, it has to be a string'); + } + + if (matchedQuery && typeof matchedQuery !== 'string') { + return new Error('If matchedQuery is present, it has to be a string'); + } + bodyParams.quiz_id = quizId; bodyParams.matched_facet = matchedFacet; bodyParams.matched_query = matchedQuery;