diff --git a/src/controllers/llmo/llmo-onboarding.js b/src/controllers/llmo/llmo-onboarding.js index 568993af1..89e40f52e 100644 --- a/src/controllers/llmo/llmo-onboarding.js +++ b/src/controllers/llmo/llmo-onboarding.js @@ -1496,3 +1496,70 @@ export async function performLlmoOffboarding(site, config, context) { message: 'LLMO offboarding completed successfully', }; } + +export async function appendRowsToQueryIndex(dataFolder, fileNames, env, log) { + const sharepointClient = await createSharePointClient(env); + const redirects = sharepointClient.getRedirects(); + + const now = Math.floor(Date.now() / 1000); + const rows = fileNames.map((fileName) => { + const name = fileName.endsWith('.json') ? fileName : `${fileName}.json`; + return [ + `/${dataFolder}/${name}`, + now, + now, + ]; + }); + + log.info(`Appending ${rows.length} rows to query-index.xlsx in ${dataFolder}`); + await redirects.appendRowsToSheet(`/${dataFolder}/query-index.xlsx`, rows); + log.info(`Successfully appended rows to query-index.xlsx in ${dataFolder}`); +} + +export async function previewAndPublishQueryIndex(dataFolder, env, log) { + const org = 'adobe'; + const site = 'project-elmo-ui-data'; + const ref = 'main'; + const baseUrl = 'https://admin.hlx.page'; + const filePath = `${dataFolder}/query-index.json`; + + if (!env.HLX_ONBOARDING_TOKEN) { + throw new Error('HLX_ONBOARDING_TOKEN is not set'); + } + + const headers = { + Cookie: `auth_token=${env.HLX_ONBOARDING_TOKEN}`, + }; + + const fetchOptions = { method: 'POST', headers, timeout: 30000 }; + + const previewUrl = `${baseUrl}/preview/${org}/${site}/${ref}/${filePath}`; + log.info(`Previewing query-index at ${previewUrl}`); + const previewResponse = await fetch(previewUrl, fetchOptions); + if (!previewResponse.ok) { + const errorCode = previewResponse.headers?.get('x-error-code') || ''; + const errorMsg = previewResponse.headers?.get('x-error') || ''; + let bodyText = ''; + try { + bodyText = await previewResponse.text(); + } catch { /* noop */ } + log.error(`Preview failed: ${previewResponse.status} ${previewResponse.statusText} | x-error-code: ${errorCode} | x-error: ${errorMsg} | body: ${bodyText}`); + throw new Error(`Preview failed: ${previewResponse.status} ${previewResponse.statusText}`); + } + log.info('Preview of query-index succeeded'); + + const publishUrl = `${baseUrl}/live/${org}/${site}/${ref}/${filePath}`; + log.info(`Publishing query-index at ${publishUrl}`); + const publishResponse = await fetch(publishUrl, fetchOptions); + if (!publishResponse.ok) { + const errorCode = publishResponse.headers?.get('x-error-code') || ''; + const errorMsg = publishResponse.headers?.get('x-error') || ''; + let bodyText = ''; + try { + bodyText = await publishResponse.text(); + } catch { /* noop */ } + log.error(`Publish failed: ${publishResponse.status} ${publishResponse.statusText} | x-error-code: ${errorCode} | x-error: ${errorMsg} | body: ${bodyText}`); + throw new Error(`Publish failed: ${publishResponse.status} ${publishResponse.statusText}`); + } + log.info('Publish of query-index succeeded'); +} diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index eaf159cfa..b007d2a1e 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -53,6 +53,8 @@ import { performLlmoOnboarding, performLlmoOffboarding, postLlmoAlert, + appendRowsToQueryIndex, + previewAndPublishQueryIndex, } from './llmo-onboarding.js'; import { queryLlmoFiles } from './llmo-query-handler.js'; import { updateModifiedByDetails } from './llmo-config-metadata.js'; @@ -1773,6 +1775,68 @@ function LlmoController(ctx) { } }; + const updateQueryIndex = async (context) => { + const { log, env } = context; + const { data } = context; + + try { + if (!accessControlUtil.isLLMOAdministrator()) { + return forbidden('Only LLMO administrators can update the query index'); + } + + if (!data || typeof data !== 'object') { + return badRequest('Request body is required'); + } + + const { domain, fileNames } = data; + + if (!domain) { + return badRequest('domain is required'); + } + + if (!Array.isArray(fileNames) || fileNames.length === 0) { + return badRequest('fileNames must be a non-empty array of strings'); + } + + if (fileNames.some((f) => typeof f !== 'string' || !f.trim())) { + return badRequest('Each fileName must be a non-empty string'); + } + + const { dataAccess } = context; + const { Site } = dataAccess; + + const baseURL = composeBaseURL(domain); + const site = await Site.findByBaseURL(baseURL); + if (!site) { + return notFound(`Site not found for domain: ${domain}`); + } + + const config = site.getConfig(); + const llmoConfig = config.getLlmoConfig(); + + if (!llmoConfig?.dataFolder) { + return badRequest('LLMO is not onboarded for this site, dataFolder is missing'); + } + + const { dataFolder } = llmoConfig; + + await appendRowsToQueryIndex(dataFolder, fileNames, env, log); + await previewAndPublishQueryIndex(dataFolder, env, log); + + log.info(`Successfully updated query-index.xlsx for domain ${domain} with ${fileNames.length} entries`); + + return ok({ + message: 'query-index.xlsx updated, previewed, and published successfully', + domain, + dataFolder, + entriesAdded: fileNames.length, + }); + } catch (error) { + log.error(`Failed to update query-index for domain ${data?.domain}: ${error.message}`); + return internalServerError(`Failed to update query-index: ${error.message}`); + } + }; + return { getLlmoSheetData, queryLlmoSheetData, @@ -1804,6 +1868,7 @@ function LlmoController(ctx) { checkEdgeOptimizeStatus, updateEdgeOptimizeCDNRouting, markOpportunitiesReviewed, + updateQueryIndex, }; } diff --git a/src/routes/index.js b/src/routes/index.js index fe0c429f0..b5a6e1cfd 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -414,6 +414,7 @@ export default function getRouteHandlers( 'GET /sites/:siteId/llmo/strategy/demo/brand-presence': llmoController.getDemoBrandPresence, 'GET /sites/:siteId/llmo/strategy/demo/recommendations': llmoController.getDemoRecommendations, 'POST /llmo/onboard': llmoController.onboardCustomer, + 'POST /llmo/onboard/update-query-index': llmoController.updateQueryIndex, 'POST /sites/:siteId/llmo/offboard': llmoController.offboardCustomer, 'POST /sites/:siteId/llmo/edge-optimize-config': llmoController.createOrUpdateEdgeConfig, 'GET /sites/:siteId/llmo/edge-optimize-config': llmoController.getEdgeConfig, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 4802b61ae..4e94f3653 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -69,6 +69,7 @@ export const INTERNAL_ROUTES = [ 'GET /sites/:siteId/llmo/strategy/demo/brand-presence', 'GET /sites/:siteId/llmo/strategy/demo/recommendations', 'POST /llmo/onboard', + 'POST /llmo/onboard/update-query-index', 'POST /sites/:siteId/llmo/offboard', 'POST /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', diff --git a/test/controllers/llmo/llmo-onboarding.test.js b/test/controllers/llmo/llmo-onboarding.test.js index a437fc22e..0bccad3c5 100644 --- a/test/controllers/llmo/llmo-onboarding.test.js +++ b/test/controllers/llmo/llmo-onboarding.test.js @@ -3793,4 +3793,240 @@ describe('LLMO Onboarding Functions', () => { expect(mockSiteConfig.enableImport).to.have.been.calledTwice; }); }); + + describe('appendRowsToQueryIndex', () => { + it('should append rows with correct format and timestamps', async () => { + const mockAppendRowsToSheet = sinon.stub().resolves(); + const mockRedirects = { appendRowsToSheet: mockAppendRowsToSheet }; + const mockSPClient = { getRedirects: sinon.stub().returns(mockRedirects) }; + + const { appendRowsToQueryIndex } = await esmock( + '../../../src/controllers/llmo/llmo-onboarding.js', + { + '@adobe/spacecat-helix-content-sdk': { + createFrom: sinon.stub().resolves(mockSPClient), + }, + }, + ); + + await appendRowsToQueryIndex('dev/test-com', ['file1', 'file2.json'], mockEnv, mockLog); + + expect(mockAppendRowsToSheet).to.have.been.calledOnce; + const [sheetPath, rows] = mockAppendRowsToSheet.firstCall.args; + expect(sheetPath).to.equal('/dev/test-com/query-index.xlsx'); + expect(rows).to.have.length(2); + expect(rows[0][0]).to.equal('/dev/test-com/file1.json'); + expect(rows[1][0]).to.equal('/dev/test-com/file2.json'); + expect(rows[0][1]).to.be.a('number'); + expect(rows[0][2]).to.be.a('number'); + expect(mockLog.info).to.have.been.calledWith(sinon.match(/Appending 2 rows/)); + expect(mockLog.info).to.have.been.calledWith(sinon.match(/Successfully appended rows/)); + }); + + it('should not double-append .json extension for files already ending in .json', async () => { + const mockAppendRowsToSheet = sinon.stub().resolves(); + const mockRedirects = { appendRowsToSheet: mockAppendRowsToSheet }; + const mockSPClient = { getRedirects: sinon.stub().returns(mockRedirects) }; + + const { appendRowsToQueryIndex } = await esmock( + '../../../src/controllers/llmo/llmo-onboarding.js', + { + '@adobe/spacecat-helix-content-sdk': { + createFrom: sinon.stub().resolves(mockSPClient), + }, + }, + ); + + await appendRowsToQueryIndex('dev/test-com', ['already.json'], mockEnv, mockLog); + + const [, rows] = mockAppendRowsToSheet.firstCall.args; + expect(rows[0][0]).to.equal('/dev/test-com/already.json'); + }); + }); + + describe('previewAndPublishQueryIndex', () => { + it('should successfully preview and publish with .json path', async () => { + const mockTracingFetch = sinon.stub(); + mockTracingFetch.onCall(0).resolves({ ok: true, status: 200, statusText: 'OK' }); + mockTracingFetch.onCall(1).resolves({ ok: true, status: 200, statusText: 'OK' }); + + const { previewAndPublishQueryIndex } = await esmock( + '../../../src/controllers/llmo/llmo-onboarding.js', + { + '@adobe/spacecat-shared-utils': { + tracingFetch: mockTracingFetch, + }, + }, + ); + + await previewAndPublishQueryIndex('dev/test-com', mockEnv, mockLog); + + expect(mockTracingFetch).to.have.been.calledTwice; + const previewCall = mockTracingFetch.firstCall; + expect(previewCall.args[0]).to.equal( + 'https://admin.hlx.page/preview/adobe/project-elmo-ui-data/main/dev/test-com/query-index.json', + ); + expect(previewCall.args[1]).to.deep.include({ method: 'POST', timeout: 30000 }); + + const publishCall = mockTracingFetch.secondCall; + expect(publishCall.args[0]).to.equal( + 'https://admin.hlx.page/live/adobe/project-elmo-ui-data/main/dev/test-com/query-index.json', + ); + expect(publishCall.args[1]).to.deep.include({ method: 'POST', timeout: 30000 }); + expect(mockLog.info).to.have.been.calledWith('Preview of query-index succeeded'); + expect(mockLog.info).to.have.been.calledWith('Publish of query-index succeeded'); + }); + + it('should throw when HLX_ONBOARDING_TOKEN is not set', async () => { + const { previewAndPublishQueryIndex } = await esmock( + '../../../src/controllers/llmo/llmo-onboarding.js', + {}, + ); + + const envWithoutToken = { ...mockEnv, HLX_ONBOARDING_TOKEN: '' }; + + try { + await previewAndPublishQueryIndex('dev/test-com', envWithoutToken, mockLog); + expect.fail('Should have thrown'); + } catch (error) { + expect(error.message).to.equal('HLX_ONBOARDING_TOKEN is not set'); + } + }); + + it('should throw and log details when preview fails', async () => { + const mockHeaders = { get: sinon.stub() }; + mockHeaders.get.withArgs('x-error-code').returns('CONTENT_NOT_FOUND'); + mockHeaders.get.withArgs('x-error').returns('resource not found'); + + const mockTracingFetch = sinon.stub().resolves({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: mockHeaders, + text: sinon.stub().resolves('detailed error body'), + }); + + const { previewAndPublishQueryIndex } = await esmock( + '../../../src/controllers/llmo/llmo-onboarding.js', + { + '@adobe/spacecat-shared-utils': { + tracingFetch: mockTracingFetch, + }, + }, + ); + + try { + await previewAndPublishQueryIndex('dev/test-com', mockEnv, mockLog); + expect.fail('Should have thrown'); + } catch (error) { + expect(error.message).to.equal('Preview failed: 404 Not Found'); + } + + expect(mockLog.error).to.have.been.calledWith( + sinon.match(/Preview failed.*404.*x-error-code: CONTENT_NOT_FOUND.*x-error: resource not found.*body: detailed error body/), + ); + }); + + it('should throw and log details when publish fails', async () => { + const mockHeaders = { get: sinon.stub() }; + mockHeaders.get.withArgs('x-error-code').returns(''); + mockHeaders.get.withArgs('x-error').returns('throttled'); + + const mockTracingFetch = sinon.stub(); + mockTracingFetch.onCall(0).resolves({ ok: true, status: 200, statusText: 'OK' }); + mockTracingFetch.onCall(1).resolves({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + headers: mockHeaders, + text: sinon.stub().resolves(''), + }); + + const { previewAndPublishQueryIndex } = await esmock( + '../../../src/controllers/llmo/llmo-onboarding.js', + { + '@adobe/spacecat-shared-utils': { + tracingFetch: mockTracingFetch, + }, + }, + ); + + try { + await previewAndPublishQueryIndex('dev/test-com', mockEnv, mockLog); + expect.fail('Should have thrown'); + } catch (error) { + expect(error.message).to.equal('Publish failed: 503 Service Unavailable'); + } + + expect(mockLog.error).to.have.been.calledWith( + sinon.match(/Publish failed.*503/), + ); + }); + + it('should handle text() throwing when reading error body', async () => { + const mockHeaders = { get: sinon.stub().returns('') }; + + const mockTracingFetch = sinon.stub().resolves({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: mockHeaders, + text: sinon.stub().rejects(new Error('stream error')), + }); + + const { previewAndPublishQueryIndex } = await esmock( + '../../../src/controllers/llmo/llmo-onboarding.js', + { + '@adobe/spacecat-shared-utils': { + tracingFetch: mockTracingFetch, + }, + }, + ); + + try { + await previewAndPublishQueryIndex('dev/test-com', mockEnv, mockLog); + expect.fail('Should have thrown'); + } catch (error) { + expect(error.message).to.equal('Preview failed: 500 Internal Server Error'); + } + + expect(mockLog.error).to.have.been.calledWith( + sinon.match(/Preview failed.*500.*body: $/), + ); + }); + + it('should handle text() throwing when reading publish error body', async () => { + const mockHeaders = { get: sinon.stub().returns('') }; + + const mockTracingFetch = sinon.stub(); + mockTracingFetch.onFirstCall().resolves({ ok: true }); + mockTracingFetch.onSecondCall().resolves({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + headers: mockHeaders, + text: sinon.stub().rejects(new Error('stream error')), + }); + + const { previewAndPublishQueryIndex } = await esmock( + '../../../src/controllers/llmo/llmo-onboarding.js', + { + '@adobe/spacecat-shared-utils': { + tracingFetch: mockTracingFetch, + }, + }, + ); + + try { + await previewAndPublishQueryIndex('dev/test-com', mockEnv, mockLog); + expect.fail('Should have thrown'); + } catch (error) { + expect(error.message).to.equal('Publish failed: 502 Bad Gateway'); + } + + expect(mockLog.error).to.have.been.calledWith( + sinon.match(/Publish failed.*502.*body: $/), + ); + }); + }); }); diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index c826ae7b8..1c9a0ea72 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -6393,4 +6393,204 @@ describe('LlmoController', () => { expect(result.status).to.equal(404); }); }); + + describe('updateQueryIndex', () => { + let updateQueryIndexController; + let appendRowsStub; + let previewAndPublishStub; + + before(async () => { + appendRowsStub = sinon.stub().resolves(); + previewAndPublishStub = sinon.stub().resolves(); + + const LlmoControllerForQueryIndex = await esmock( + '../../../src/controllers/llmo/llmo.js', + { + '../../../src/controllers/llmo/llmo-onboarding.js': { + appendRowsToQueryIndex: (...args) => appendRowsStub(...args), + previewAndPublishQueryIndex: (...args) => previewAndPublishStub(...args), + }, + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '@adobe/spacecat-shared-utils': { + tracingFetch: sinon.stub(), + composeBaseURL: (domain) => (domain.startsWith('http') ? domain : `https://${domain}`), + hasText: (str) => typeof str === 'string' && str.trim().length > 0, + isObject: (obj) => obj !== null && typeof obj === 'object' && !Array.isArray(obj), + }, + '../../../src/support/access-control-util.js': { + default: createMockAccessControlUtil(true, true, true), + }, + '@adobe/spacecat-shared-tokowaka-client': { + default: { createFrom: () => mockTokowakaClient }, + calculateForwardedHost: () => 'www.example.com', + }, + '../../../src/utils/slack/base.js': { + postSlackMessage: sinon.stub(), + }, + }, + ); + updateQueryIndexController = LlmoControllerForQueryIndex; + }); + + beforeEach(() => { + appendRowsStub.reset(); + appendRowsStub.resolves(); + previewAndPublishStub.reset(); + previewAndPublishStub.resolves(); + mockDataAccess.Site.findByBaseURL = sinon.stub().resolves(mockSite); + }); + + it('should successfully update query-index', async () => { + const ctx = { + ...mockContext, + data: { domain: 'example.com', fileNames: ['file1', 'file2.json'] }, + }; + const ctrl = updateQueryIndexController(ctx); + const result = await ctrl.updateQueryIndex(ctx); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.message).to.include('updated, previewed, and published'); + expect(body.entriesAdded).to.equal(2); + expect(appendRowsStub).to.have.been.calledOnce; + expect(previewAndPublishStub).to.have.been.calledOnce; + }); + + it('should return forbidden when user is not LLMO administrator', async () => { + const LlmoControllerDeniedAdmin = await esmock( + '../../../src/controllers/llmo/llmo.js', + { + '../../../src/controllers/llmo/llmo-onboarding.js': { + appendRowsToQueryIndex: sinon.stub(), + previewAndPublishQueryIndex: sinon.stub(), + }, + '@adobe/spacecat-shared-http-utils': mockHttpUtils, + '@adobe/spacecat-shared-utils': { + tracingFetch: sinon.stub(), + composeBaseURL: (d) => `https://${d}`, + hasText: (s) => typeof s === 'string' && s.trim().length > 0, + isObject: (o) => o !== null && typeof o === 'object' && !Array.isArray(o), + }, + '../../../src/support/access-control-util.js': { + default: createMockAccessControlUtil(true, true, false), + }, + '@adobe/spacecat-shared-tokowaka-client': { + default: { createFrom: () => mockTokowakaClient }, + calculateForwardedHost: () => 'www.example.com', + }, + '../../../src/utils/slack/base.js': { + postSlackMessage: sinon.stub(), + }, + }, + ); + const ctx = { + ...mockContext, + data: { domain: 'example.com', fileNames: ['file1'] }, + }; + const ctrl = LlmoControllerDeniedAdmin(ctx); + const result = await ctrl.updateQueryIndex(ctx); + + expect(result.status).to.equal(403); + }); + + it('should return bad request when body is missing', async () => { + const ctx = { ...mockContext, data: null }; + const ctrl = updateQueryIndexController(ctx); + const result = await ctrl.updateQueryIndex(ctx); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('Request body is required'); + }); + + it('should return bad request when domain is missing', async () => { + const ctx = { ...mockContext, data: { fileNames: ['file1'] } }; + const ctrl = updateQueryIndexController(ctx); + const result = await ctrl.updateQueryIndex(ctx); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('domain is required'); + }); + + it('should return bad request when fileNames is not a non-empty array', async () => { + const ctx = { ...mockContext, data: { domain: 'example.com', fileNames: [] } }; + const ctrl = updateQueryIndexController(ctx); + const result = await ctrl.updateQueryIndex(ctx); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('fileNames must be a non-empty array of strings'); + }); + + it('should return bad request when fileNames contains non-string entries', async () => { + const ctx = { ...mockContext, data: { domain: 'example.com', fileNames: ['valid', ''] } }; + const ctrl = updateQueryIndexController(ctx); + const result = await ctrl.updateQueryIndex(ctx); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.equal('Each fileName must be a non-empty string'); + }); + + it('should return not found when site does not exist', async () => { + mockDataAccess.Site.findByBaseURL.resolves(null); + + const ctx = { + ...mockContext, + data: { domain: 'unknown.com', fileNames: ['file1'] }, + }; + const ctrl = updateQueryIndexController(ctx); + const result = await ctrl.updateQueryIndex(ctx); + + expect(result.status).to.equal(404); + }); + + it('should return bad request when dataFolder is missing from llmo config', async () => { + mockConfig.getLlmoConfig.returns({}); + + const ctx = { + ...mockContext, + data: { domain: 'example.com', fileNames: ['file1'] }, + }; + const ctrl = updateQueryIndexController(ctx); + const result = await ctrl.updateQueryIndex(ctx); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('dataFolder is missing'); + }); + + it('should return internal server error when appendRows throws', async () => { + appendRowsStub.rejects(new Error('SharePoint connection failed')); + mockConfig.getLlmoConfig.returns({ dataFolder: TEST_FOLDER }); + + const ctx = { + ...mockContext, + data: { domain: 'example.com', fileNames: ['file1'] }, + }; + const ctrl = updateQueryIndexController(ctx); + const result = await ctrl.updateQueryIndex(ctx); + + expect(result.status).to.equal(500); + const body = await result.json(); + expect(body.message).to.include('SharePoint connection failed'); + }); + + it('should return internal server error when previewAndPublish throws', async () => { + previewAndPublishStub.rejects(new Error('Preview failed: 503 Service Unavailable')); + mockConfig.getLlmoConfig.returns({ dataFolder: TEST_FOLDER }); + + const ctx = { + ...mockContext, + data: { domain: 'example.com', fileNames: ['file1'] }, + }; + const ctrl = updateQueryIndexController(ctx); + const result = await ctrl.updateQueryIndex(ctx); + + expect(result.status).to.equal(500); + const body = await result.json(); + expect(body.message).to.include('Preview failed'); + }); + }); }); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 5124ecaa8..e451b667b 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -308,6 +308,7 @@ describe('getRouteHandlers', () => { getDemoBrandPresence: () => null, getDemoRecommendations: () => null, markOpportunitiesReviewed: () => null, + updateQueryIndex: () => null, }; const mockSandboxAuditController = { @@ -496,6 +497,7 @@ describe('getRouteHandlers', () => { 'POST /tools/scrape/jobs', 'POST /consent-banner', 'POST /llmo/onboard', + 'POST /llmo/onboard/update-query-index', 'GET /llmo/agentic-traffic/global', 'POST /llmo/agentic-traffic/global', 'POST /plg/onboard', @@ -525,6 +527,7 @@ describe('getRouteHandlers', () => { expect(staticRoutes['POST /consent-banner']).to.equal(mockConsentBannerController.takeScreenshots); expect(staticRoutes['POST /tools/scrape/jobs']).to.equal(mockScrapeJobController.createScrapeJob); expect(staticRoutes['POST /llmo/onboard']).to.equal(mockLlmoController.onboardCustomer); + expect(staticRoutes['POST /llmo/onboard/update-query-index']).to.equal(mockLlmoController.updateQueryIndex); expect(staticRoutes['GET /llmo/agentic-traffic/global']).to.equal(mockLlmoMysticatController.getAgenticTrafficGlobal); expect(staticRoutes['POST /llmo/agentic-traffic/global']).to.equal(mockLlmoMysticatController.postAgenticTrafficGlobal); expect(staticRoutes['POST /plg/onboard']).to.equal(mockPlgOnboardingController.onboard);