Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/controllers/llmo/llmo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -765,7 +783,7 @@ function LlmoController(ctx) {
patchLlmoCustomerIntent,
patchLlmoCdnLogsFilter,
patchLlmoCdnBucketConfig,
postLlmoConfig,
updateLlmoConfig,
onboardCustomer,
};
}
Expand Down
3 changes: 2 additions & 1 deletion src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
215 changes: 208 additions & 7 deletions test/controllers/llmo.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -204,6 +205,9 @@ describe('LlmoController', () => {
s3Client,
s3Bucket: 'test-bucket',
},
sqs: {
sendMessage: sinon.stub().resolves(),
},
attributes: {
authInfo: {
getType: () => 'jwt',
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions test/routes/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down