diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 18ae2913a..cd1a87248 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -382,7 +382,7 @@ function LlmoController(ctx) { } }; - async function postLlmoConfig(context) { + async function updateLlmoConfig(context) { const { log, s3, data } = context; const { siteId } = context.params; try { @@ -394,6 +394,8 @@ function LlmoController(ctx) { return badRequest('LLMO config storage is not configured for this environment'); } + const prevConfig = await readConfig(siteId, s3.s3Client, { s3Bucket: s3.s3Bucket }); + // Validate the config, return 400 if validation fails const result = llmoConfigSchema.safeParse(data); if (!result.success) { @@ -405,13 +407,29 @@ function LlmoController(ctx) { } const parsedConfig = result.data; + const newConfig = { + ...(prevConfig?.exists && { ...prevConfig.config }), + ...parsedConfig, + }; + const { version } = await writeConfig( siteId, - parsedConfig, + newConfig, s3.s3Client, { s3Bucket: s3.s3Bucket }, ); + // Trigger llmo-customer-analysis after config is updated + await context.sqs.sendMessage(context.env.AUDIT_JOBS_QUEUE_URL, { + type: 'llmo-customer-analysis', + siteId, + auditContext: {}, + data: { + configVersion: version, + previousConfigVersion: prevConfig.exists ? prevConfig.version : /* c8 ignore next */ null, + }, + }); + log.info(`Updated LLMO config in S3 for siteId: ${siteId}, version: ${version}`); return ok({ version }); } catch (error) { @@ -765,7 +783,7 @@ function LlmoController(ctx) { patchLlmoCustomerIntent, patchLlmoCdnLogsFilter, patchLlmoCdnBucketConfig, - postLlmoConfig, + updateLlmoConfig, onboardCustomer, }; } diff --git a/src/routes/index.js b/src/routes/index.js index b8b46ba7a..ec37f8dfb 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -248,7 +248,8 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/sheet-data/:dataSource': llmoController.queryLlmoSheetData, 'POST /sites/:siteId/llmo/sheet-data/:sheetType/:dataSource': llmoController.queryLlmoSheetData, 'GET /sites/:siteId/llmo/config': llmoController.getLlmoConfig, - 'POST /sites/:siteId/llmo/config': llmoController.postLlmoConfig, + 'PATCH /sites/:siteId/llmo/config': llmoController.updateLlmoConfig, + 'POST /sites/:siteId/llmo/config': llmoController.updateLlmoConfig, 'GET /sites/:siteId/llmo/questions': llmoController.getLlmoQuestions, 'POST /sites/:siteId/llmo/questions': llmoController.addLlmoQuestion, 'DELETE /sites/:siteId/llmo/questions/:questionKey': llmoController.removeLlmoQuestion, diff --git a/test/controllers/llmo.test.js b/test/controllers/llmo.test.js index 5cb9cad78..a17d06cc0 100644 --- a/test/controllers/llmo.test.js +++ b/test/controllers/llmo.test.js @@ -179,6 +179,7 @@ describe('LlmoController', () => { // Create mock environment mockEnv = { LLMO_HLX_API_KEY: 'test-api-key', + AUDIT_JOBS_QUEUE_URL: 'https://sqs.us-east-1.amazonaws.com/123456789012/audit-jobs-queue', }; // Create mock context @@ -204,6 +205,9 @@ describe('LlmoController', () => { s3Client, s3Bucket: 'test-bucket', }, + sqs: { + sendMessage: sinon.stub().resolves(), + }, attributes: { authInfo: { getType: () => 'jwt', @@ -2396,9 +2400,127 @@ describe('LlmoController', () => { }); }); - describe('postLlmoConfig', () => { - it('should write config to S3 successfully', async () => { + describe('updateLlmoConfig', () => { + it('should write config to S3 successfully when no prev config', async () => { + writeConfigStub.resolves({ version: 'v1' }); + readConfigStub.resolves({ + config: llmoConfig.defaultConfig(), + exists: false, + version: null, + }); + + const categoryId = '123e4567-e89b-12d3-a456-426614174000'; + const topicId = '123e4567-e89b-12d3-a456-426614174001'; + + const testData = { + entities: { + [categoryId]: { type: 'category', name: 'test-category' }, + [topicId]: { type: 'topic', name: 'test-topic' }, + }, + categories: { + [categoryId]: { name: 'test-category', region: ['us'] }, + }, + topics: { + [topicId]: { + name: 'test-topic', + category: categoryId, + prompts: [{ + prompt: 'What is the main topic?', + regions: ['us'], + origin: 'human', + source: 'config', + }], + }, + }, + brands: { + aliases: [{ + aliases: ['test-brand'], + category: categoryId, + region: ['us'], + }], + }, + competitors: { + competitors: [{ + name: 'test-competitor', + category: categoryId, + region: ['us'], + aliases: ['competitor-alias'], + urls: [], + }], + }, + }; + + mockContext.data = testData; + + // Mock successful validation + llmoConfigSchemaStub.safeParse.returns({ success: true, data: testData }); + + const result = await controller.updateLlmoConfig(mockContext); + + expect(result.status).to.equal(200); + const responseBody = await result.json(); + expect(responseBody).to.deep.equal({ version: 'v1' }); + expect(llmoConfigSchemaStub.safeParse).to.have.been.calledWith(testData); + expect(writeConfigStub).to.have.been.calledWith( + 'test-site-id', + testData, + s3Client, + { s3Bucket: 'test-bucket' }, + ); + }); + + it('should write config to S3 successfully overrides existing fields in prev config', async () => { + writeConfigStub.resolves({ version: 'v1' }); + readConfigStub.resolves({ + config: { + field1: [1, 2, 3], + field2: [2, 3], + field3: [3, 4], + }, + exists: true, + version: 'some-old-version', + }); + + const testData = { + field1: [1, 2, 3], + field3: null, + field4: [3, 4], + }; + + const expectedConfig = { + field1: [1, 2, 3], + field2: [2, 3], + field3: null, + field4: [3, 4], + }; + + mockContext.data = testData; + + // Mock successful validation + llmoConfigSchemaStub.safeParse.returns({ success: true, data: testData }); + + const result = await controller.updateLlmoConfig(mockContext); + + expect(result.status).to.equal(200); + const responseBody = await result.json(); + expect(responseBody).to.deep.equal({ version: 'v1' }); + expect(llmoConfigSchemaStub.safeParse).to.have.been.calledWith(testData); + expect(writeConfigStub).to.have.been.calledWith( + 'test-site-id', + expectedConfig, + s3Client, + { s3Bucket: 'test-bucket' }, + ); + }); + + it('should write config to S3 successfully when no prev config', async () => { writeConfigStub.resolves({ version: 'v1' }); + readConfigStub.resolves({ + config: llmoConfig.defaultConfig(), + exists: false, + version: null, + }); + const categoryId = '123e4567-e89b-12d3-a456-426614174000'; const topicId = '123e4567-e89b-12d3-a456-426614174001'; @@ -2445,7 +2567,7 @@ describe('LlmoController', () => { // Mock successful validation llmoConfigSchemaStub.safeParse.returns({ success: true, data: testData }); - const result = await controller.postLlmoConfig(mockContext); + const result = await controller.updateLlmoConfig(mockContext); expect(result.status).to.equal(200); const responseBody = await result.json(); @@ -2459,10 +2581,84 @@ describe('LlmoController', () => { ); }); + it('triggers the "llmo-customer-analysis" audit after writing the new config', async () => { + writeConfigStub.resolves({ version: 'v1' }); + readConfigStub.resolves({ + config: llmoConfig.defaultConfig(), + exists: true, + version: 'v0', + }); + + const categoryId = '123e4567-e89b-12d3-a456-426614174000'; + const topicId = '123e4567-e89b-12d3-a456-426614174001'; + + const testData = { + entities: { + [categoryId]: { type: 'category', name: 'test-category' }, + [topicId]: { type: 'topic', name: 'test-topic' }, + }, + categories: { + [categoryId]: { name: 'test-category', region: ['us'] }, + }, + topics: { + [topicId]: { + name: 'test-topic', + category: categoryId, + prompts: [{ + prompt: 'What is the main topic?', + regions: ['us'], + origin: 'human', + source: 'config', + }], + }, + }, + brands: { + aliases: [{ + aliases: ['test-brand'], + category: categoryId, + region: ['us'], + }], + }, + competitors: { + competitors: [{ + name: 'test-competitor', + category: categoryId, + region: ['us'], + aliases: ['competitor-alias'], + urls: [], + }], + }, + }; + + mockContext.data = testData; + + // Mock successful validation + llmoConfigSchemaStub.safeParse.returns({ success: true, data: testData }); + + const result = await controller.updateLlmoConfig(mockContext); + + expect(result.status).to.equal(200); + + // Verify SQS message was sent with correct audit configuration + expect(mockContext.sqs.sendMessage).to.have.been.calledOnce; + expect(mockContext.sqs.sendMessage).to.have.been.calledWith( + 'https://sqs.us-east-1.amazonaws.com/123456789012/audit-jobs-queue', + { + type: 'llmo-customer-analysis', + siteId: 'test-site-id', + auditContext: {}, + data: { + configVersion: 'v1', + previousConfigVersion: 'v0', + }, + }, + ); + }); + it('should return bad request when payload is not an object', async () => { mockContext.data = null; - const result = await controller.postLlmoConfig(mockContext); + const result = await controller.updateLlmoConfig(mockContext); expect(result.status).to.equal(400); const responseBody = await result.json(); @@ -2485,7 +2681,7 @@ describe('LlmoController', () => { }, }); - const result = await controller.postLlmoConfig(mockContext); + const result = await controller.updateLlmoConfig(mockContext); expect(result.status).to.equal(400); const responseBody = await result.json(); @@ -2497,7 +2693,7 @@ describe('LlmoController', () => { it('should return bad request when s3 client is missing', async () => { delete mockContext.s3; - const result = await controller.postLlmoConfig(mockContext); + const result = await controller.updateLlmoConfig(mockContext); expect(result.status).to.equal(400); const responseBody = await result.json(); @@ -2507,6 +2703,11 @@ describe('LlmoController', () => { }); it('should handle S3 error when writing config', async () => { + readConfigStub.resolves({ + config: llmoConfig.defaultConfig(), + exists: true, + version: 'v0', + }); writeConfigStub.rejects(new Error('S3 write failed')); const categoryId = '123e4567-e89b-12d3-a456-426614174000'; @@ -2555,7 +2756,7 @@ describe('LlmoController', () => { // Mock successful validation llmoConfigSchemaStub.safeParse.returns({ success: true, data: testData }); - const result = await controller.postLlmoConfig(mockContext); + const result = await controller.updateLlmoConfig(mockContext); expect(result.status).to.equal(400); const responseBody = await result.json(); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 6b2705508..ba83e90fb 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -415,6 +415,7 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/sheet-data/:dataSource', 'POST /sites/:siteId/llmo/sheet-data/:sheetType/:dataSource', 'GET /sites/:siteId/llmo/config', + 'PATCH /sites/:siteId/llmo/config', 'POST /sites/:siteId/llmo/config', 'GET /sites/:siteId/llmo/questions', 'POST /sites/:siteId/llmo/questions',