Skip to content

Commit 6fcbd51

Browse files
anihamani hammond
andauthored
feat: get llmo config v2 (#1340)
## Add V2 Customer Config S3 Utilities Added organization-level customer configuration management functions to `llmo-config.js`: - `customerConfigV2Path(organizationId)` - Returns S3 path for organization configs - `readCustomerConfigV2(organizationId, s3Client, options)` - Reads config from S3, returns `null` if not found - `writeCustomerConfigV2(organizationId, config, s3Client, options)` - Writes config to S3 All functions follow existing LLMO config patterns with environment/custom bucket support and comprehensive test coverage. Co-authored-by: ani hammond <annhammo@MacBookPro.lan>
1 parent 9d5d3f7 commit 6fcbd51

File tree

2 files changed

+179
-0
lines changed

2 files changed

+179
-0
lines changed

packages/spacecat-shared-utils/src/llmo-config.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,67 @@ export async function writeConfig(siteId, config, s3Client, options) {
125125
}
126126
return { version: res.VersionId };
127127
}
128+
129+
/**
130+
* Gets the S3 path for a V2 customer configuration.
131+
* @param {string} organizationId The SpaceCat organization ID.
132+
* @returns {string} The S3 key path for the V2 customer config.
133+
*/
134+
export function customerConfigV2Path(organizationId) {
135+
return `customer-config-v2/${organizationId}/config.json`;
136+
}
137+
138+
/**
139+
* Reads the V2 customer configuration for an organization from S3.
140+
* @param {string} organizationId The SpaceCat organization ID.
141+
* @param {S3Client} s3Client The S3 client to use for reading the configuration.
142+
* @param {object} [options]
143+
* @param {string} [options.s3Bucket] Optional S3 bucket name.
144+
* @returns {Promise<object|null>} The configuration object or null if not found.
145+
* @throws {Error} If reading the configuration fails for reasons other than it not existing.
146+
*/
147+
export async function readCustomerConfigV2(organizationId, s3Client, options) {
148+
const s3Bucket = options?.s3Bucket || process.env.S3_BUCKET_NAME;
149+
150+
const getObjectCommand = new GetObjectCommand({
151+
Bucket: s3Bucket,
152+
Key: customerConfigV2Path(organizationId),
153+
});
154+
155+
try {
156+
const res = await s3Client.send(getObjectCommand);
157+
const body = res.Body;
158+
if (!body) {
159+
throw new Error('Customer config V2 body is empty');
160+
}
161+
const text = await body.transformToString();
162+
return JSON.parse(text);
163+
} catch (e) {
164+
if (e.name === 'NoSuchKey' || e.name === 'NotFound') {
165+
return null;
166+
}
167+
throw e;
168+
}
169+
}
170+
171+
/**
172+
* Writes the V2 customer configuration for an organization to S3.
173+
* @param {string} organizationId The SpaceCat organization ID.
174+
* @param {object} config The customer configuration object to write.
175+
* @param {S3Client} s3Client The S3 client to use for writing the configuration.
176+
* @param {object} [options]
177+
* @param {string} [options.s3Bucket] Optional S3 bucket name.
178+
* @returns {Promise<void>}
179+
*/
180+
export async function writeCustomerConfigV2(organizationId, config, s3Client, options) {
181+
const s3Bucket = options?.s3Bucket || process.env.S3_BUCKET_NAME;
182+
183+
const putObjectCommand = new PutObjectCommand({
184+
Bucket: s3Bucket,
185+
Key: customerConfigV2Path(organizationId),
186+
Body: JSON.stringify(config, null, 2),
187+
ContentType: 'application/json',
188+
});
189+
190+
await s3Client.send(putObjectCommand);
191+
}

packages/spacecat-shared-utils/test/llmo-config.test.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import {
2323
defaultConfig,
2424
readConfig,
2525
writeConfig,
26+
customerConfigV2Path,
27+
readCustomerConfigV2,
28+
writeCustomerConfigV2,
2629
} from '../src/llmo-config.js';
2730

2831
use(sinonChai);
@@ -200,4 +203,116 @@ describe('llmo-config utilities', () => {
200203
await expect(writeConfig(siteId, validConfig, s3Client)).rejectedWith('Failed to get version ID after writing LLMO config');
201204
});
202205
});
206+
207+
describe('customerConfigV2Path', () => {
208+
it('builds the V2 customer config path', () => {
209+
const organizationId = 'test-org-123';
210+
expect(customerConfigV2Path(organizationId)).to.equals('customer-config-v2/test-org-123/config.json');
211+
});
212+
});
213+
214+
describe('readCustomerConfigV2', () => {
215+
const organizationId = 'test-org-456';
216+
const validCustomerConfig = { settings: { feature1: true }, limits: { maxUsers: 100 } };
217+
218+
it('retrieves and parses the customer config from S3', async () => {
219+
const body = {
220+
transformToString: sinon.stub().resolves(JSON.stringify(validCustomerConfig)),
221+
};
222+
s3Client.send.resolves({ Body: body });
223+
224+
const result = await readCustomerConfigV2(organizationId, s3Client);
225+
226+
expect(result).deep.equals(validCustomerConfig);
227+
expect(s3Client.send).calledOnce;
228+
const command = s3Client.send.firstCall.args[0];
229+
expect(command).instanceOf(GetObjectCommand);
230+
expect(command.input.Bucket).equals('default-test-bucket');
231+
expect(command.input.Key).equals('customer-config-v2/test-org-456/config.json');
232+
expect(body.transformToString).calledOnce;
233+
});
234+
235+
it('uses provided bucket when options are set', async () => {
236+
const body = {
237+
transformToString: sinon.stub().resolves(JSON.stringify(validCustomerConfig)),
238+
};
239+
s3Client.send.resolves({ Body: body });
240+
241+
await readCustomerConfigV2(organizationId, s3Client, {
242+
s3Bucket: 'custom-org-bucket',
243+
});
244+
245+
const command = s3Client.send.firstCall.args[0];
246+
expect(command.input.Bucket).equals('custom-org-bucket');
247+
});
248+
249+
it('returns null when the config does not exist (NoSuchKey)', async () => {
250+
const error = new Error('Missing key');
251+
error.name = 'NoSuchKey';
252+
s3Client.send.rejects(error);
253+
254+
const result = await readCustomerConfigV2(organizationId, s3Client);
255+
256+
expect(result).to.be.null;
257+
});
258+
259+
it('returns null when the config does not exist (NotFound)', async () => {
260+
const error = new Error('Not found');
261+
error.name = 'NotFound';
262+
s3Client.send.rejects(error);
263+
264+
const result = await readCustomerConfigV2(organizationId, s3Client);
265+
266+
expect(result).to.be.null;
267+
});
268+
269+
it('re-throws unexpected S3 errors', async () => {
270+
s3Client.send.rejects(new Error('S3 Service Error'));
271+
272+
await expect(readCustomerConfigV2(organizationId, s3Client)).rejectedWith('S3 Service Error');
273+
});
274+
275+
it('throws when the S3 object body is missing', async () => {
276+
s3Client.send.resolves({});
277+
278+
await expect(readCustomerConfigV2(organizationId, s3Client)).rejectedWith('Customer config V2 body is empty');
279+
});
280+
281+
it('throws when the S3 object body cannot be parsed as JSON', async () => {
282+
const body = {
283+
transformToString: sinon.stub().resolves('invalid json content'),
284+
};
285+
s3Client.send.resolves({ Body: body });
286+
287+
await expect(readCustomerConfigV2(organizationId, s3Client)).rejectedWith(SyntaxError);
288+
});
289+
});
290+
291+
describe('writeCustomerConfigV2', () => {
292+
const organizationId = 'test-org-789';
293+
const customerConfig = { settings: { feature2: false }, limits: { maxProjects: 50 } };
294+
295+
it('writes the customer config to the default S3 bucket', async () => {
296+
s3Client.send.resolves({});
297+
298+
await writeCustomerConfigV2(organizationId, customerConfig, s3Client);
299+
300+
expect(s3Client.send).calledOnce;
301+
const command = s3Client.send.firstCall.args[0];
302+
expect(command).instanceOf(PutObjectCommand);
303+
expect(command.input.Bucket).equals('default-test-bucket');
304+
expect(command.input.Key).equals('customer-config-v2/test-org-789/config.json');
305+
expect(command.input.Body).equals(JSON.stringify(customerConfig, null, 2));
306+
expect(command.input.ContentType).equals('application/json');
307+
});
308+
309+
it('writes the customer config to a provided bucket', async () => {
310+
s3Client.send.resolves({});
311+
312+
await writeCustomerConfigV2(organizationId, customerConfig, s3Client, { s3Bucket: 'custom-org-bucket' });
313+
314+
const command = s3Client.send.firstCall.args[0];
315+
expect(command.input.Bucket).equals('custom-org-bucket');
316+
});
317+
});
203318
});

0 commit comments

Comments
 (0)