From ba2c20b0c6d73c35eb67d39a0285518d12944bae Mon Sep 17 00:00:00 2001 From: Igor Grubic Date: Wed, 11 Mar 2026 13:14:14 +0100 Subject: [PATCH 1/5] v0 --- docs/openapi/api.yaml | 2 + docs/openapi/customer-config-api.yaml | 185 +++++ package.json | 6 +- src/controllers/brands.js | 220 +++++ src/routes/index.js | 2 + src/routes/required-capabilities.js | 2 + test/controllers/brands.test.js | 1098 +++++++++++++++++++++++++ test/routes/index.test.js | 12 + 8 files changed, 1524 insertions(+), 3 deletions(-) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 3b761225b..e5a5d2df4 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -125,6 +125,8 @@ paths: $ref: './customer-config-api.yaml#/llmo-topics-by-space-cat-id-v2' /v2/orgs/{spaceCatId}/llmo-prompts: $ref: './customer-config-api.yaml#/llmo-prompts-by-space-cat-id-v2' + /v2/orgs/{spaceCatId}/brands/{brandId}/llmo/config: + $ref: './customer-config-api.yaml#/llmo-brand-config-by-space-cat-id-v2' /organizations/{organizationId}/sites: $ref: './sites-api.yaml#/sites-for-organization' /organizations/{organizationId}/entitlements: diff --git a/docs/openapi/customer-config-api.yaml b/docs/openapi/customer-config-api.yaml index 27d9b5c15..9b647df8b 100644 --- a/docs/openapi/customer-config-api.yaml +++ b/docs/openapi/customer-config-api.yaml @@ -357,6 +357,191 @@ llmo-topics-by-space-cat-id-v2: '500': $ref: './responses.yaml#/500' +llmo-brand-config-by-space-cat-id-v2: + parameters: + - name: spaceCatId + in: path + required: true + description: SpaceCat Organization ID (UUID) + schema: + type: string + format: uuid + example: 'a1b2c3d4-5678-90ab-cdef-1234567890ab' + - name: brandId + in: path + required: true + description: Brand identifier within the customer configuration (not a UUID) + schema: + type: string + example: 'adobe' + get: + tags: + - customer-config + summary: Retrieves v1 LLMO configs for all sites in a brand + description: | + Uses the v2 customer config as a directory to resolve brand URLs to SpaceCat sites, + then fetches the v1 LLMO configuration for each resolved site. + + The flow: + 1. Loads the v2 customer config for the organization + 2. Finds the brand matching the brandId + 3. Maps each URL in the brand's urls array to a SpaceCat site via findByBaseURL + 4. Fetches the v1 LLMO config from S3 for each resolved site + + At least one brand URL must resolve to a site. URLs that cannot be resolved are + silently skipped. Sites that have no v1 config yet return config: null. + + Requires LLMO administrator access and organization membership. + operationId: getLlmoConfigForBrand + security: + - ims_key: [] + - api_key: [] + responses: + '200': + description: V1 LLMO configs for all resolved sites in the brand + content: + application/json: + schema: + type: array + items: + type: object + properties: + siteId: + type: string + format: uuid + description: SpaceCat site ID + baseURL: + type: string + description: Normalized base URL of the site + example: 'https://adobe.com' + config: + oneOf: + - $ref: './schemas.yaml#/LlmoConfig' + - type: "null" + description: V1 LLMO config for this site, or null if none exists + required: + - siteId + - baseURL + - config + '400': + $ref: './responses.yaml#/400' + '403': + $ref: './responses.yaml#/403' + '404': + description: Organization, brand, or customer configuration not found; or no URLs could be resolved + content: + application/json: + schema: + type: object + properties: + message: + type: string + '500': + $ref: './responses.yaml#/500' + patch: + tags: + - customer-config + summary: Updates v1 LLMO configs for specified sites in a brand + description: | + Writes v1 LLMO configurations for specified sites that belong to a brand. + Uses the v2 customer config to validate that each siteId belongs to the + correct organization and brand. + + For each site entry, the endpoint applies the same write pipeline as the v1 + LLMO config endpoint: + - Reads the previous config version + - Applies modification metadata tracking (updatedBy, updatedAt) + - Validates against the LLMO config schema + - Writes to S3 + + Partial failures are handled gracefully: if one site's config write fails, + the remaining sites are still processed. The response contains per-site results. + + Set the X-Trigger-Audits header to trigger llmo-customer-analysis audit jobs + for each successfully written site. + + Requires LLMO administrator access and organization membership. + operationId: patchLlmoConfigForBrand + security: + - ims_key: [] + - api_key: [] + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + siteId: + type: string + format: uuid + description: SpaceCat site ID to update + config: + $ref: './schemas.yaml#/LlmoConfig' + required: + - siteId + - config + minItems: 1 + examples: + singleSite: + summary: Update config for a single site + value: + - siteId: "a1b2c3d4-5678-90ab-cdef-1234567890ab" + config: + entities: {} + categories: {} + topics: {} + aiTopics: {} + brands: + aliases: [] + competitors: + competitors: [] + responses: + '200': + description: Per-site write results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: object + properties: + siteId: + type: string + format: uuid + status: + type: string + enum: [success, error] + version: + type: string + description: S3 version ID (present on success) + message: + type: string + description: Error message (present on error) + required: + - siteId + - status + '400': + $ref: './responses.yaml#/400' + '403': + $ref: './responses.yaml#/403' + '404': + description: Organization, brand, or customer configuration not found + content: + application/json: + schema: + type: object + properties: + message: + type: string + '500': + $ref: './responses.yaml#/500' + llmo-prompts-by-space-cat-id-v2: parameters: - name: spaceCatId diff --git a/package.json b/package.json index 869949147..e6acbc5ad 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "build": "hedy -v --test-bundle", "deploy": "hedy -v --deploy --aws-deploy-bucket=spacecat-prod-deploy --pkgVersion=latest", "deploy-stage": "hedy -v --deploy --aws-deploy-bucket=spacecat-stage-deploy --pkgVersion=latest", - "deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l latest --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h", - "deploy-secrets": "hedy --aws-update-secrets --params-file=secrets/secrets.env", + "deploy-dev": "hedy -v --deploy --pkgVersion=latest$CI_BUILD_NUM -l joselopez --aws-deploy-bucket=spacecat-dev-deploy --cleanup-ci=24h", + "deploy-secrets": "hedy --aws-update-secrets --params-file=secrets/secrets.env --aws-region=us-east-1", "docs": "npm run docs:lint && npm run docs:build", "docs:build": "npx @redocly/cli build-docs -o ./docs/index.html --config docs/openapi/redocly-config.yaml", "docs:lint": "npx @redocly/cli lint --config docs/openapi/redocly-config.yaml", @@ -167,4 +167,4 @@ ], "ext": ".js, .cjs, .ejs, .css" } -} +} \ No newline at end of file diff --git a/src/controllers/brands.js b/src/controllers/brands.js index d661e449f..f72b8cea9 100644 --- a/src/controllers/brands.js +++ b/src/controllers/brands.js @@ -23,6 +23,8 @@ import { isNonEmptyObject, isValidUUID, llmoConfig, + llmoConfig as llmo, + composeBaseURL, } from '@adobe/spacecat-shared-utils'; import { ErrorWithStatusCode, getImsUserToken } from '../support/utils.js'; @@ -31,6 +33,7 @@ import { } from '../utils/constants.js'; import AccessControlUtil from '../support/access-control-util.js'; import { mergeCustomerConfigV2 } from '../support/customer-config-v2-metadata.js'; +import LlmoController from './llmo/llmo.js'; const HEADER_ERROR = 'x-error'; @@ -637,6 +640,221 @@ function BrandsController(ctx, log, env) { } }; + const { readConfig } = llmo; + + /** + * Finds a brand by ID within a customer config. Returns the brand or a notFound response. + * @param {object} customerConfig - The v2 customer config. + * @param {string} brandId - The brand ID to find. + * @returns {object|Response} Brand object or notFound response. + */ + function findBrandOrNotFound(customerConfig, brandId) { + const brands = customerConfig?.customer?.brands || []; + const brand = brands.find((b) => b.id === brandId); + if (!brand) { + return notFound(`Brand not found: ${brandId}`); + } + return brand; + } + + /** + * Resolves brand URLs to SpaceCat sites. Returns resolved sites and any URLs + * that could not be mapped. + * @param {Array} urls - Brand URL objects with a `value` field. + * @returns {Promise<{resolved: Array, unresolved: Array}>} + */ + async function resolveBrandUrlsToSites(urls) { + const results = await Promise.all( + (urls || []).map(async (urlEntry) => { + const normalized = composeBaseURL(urlEntry.value); + const site = await Site.findByBaseURL(normalized); + return { url: urlEntry.value, normalized, site }; + }), + ); + const resolved = results + .filter((r) => r.site) + .map((r) => ({ siteId: r.site.getId(), baseURL: r.normalized, site: r.site })); + const unresolved = results + .filter((r) => !r.site) + .map((r) => r.url); + return { resolved, unresolved }; + } + + /** + * Gets v1 LLMO configs for all sites in a brand, using the v2 customer config + * as a directory to map brand URLs to site IDs. + * @param {object} context - Context of the request. + * @returns {Promise} Array of site configs. + */ + const getLlmoConfigForBrand = async (context) => { + const { spaceCatId, brandId } = context.params || {}; + try { + if (!hasText(spaceCatId)) { + return badRequest('Organization ID required'); + } + if (!hasText(brandId)) { + return badRequest('Brand ID required'); + } + + const organization = await getOrganizationOrNotFound(spaceCatId); + if (organization.status) return organization; + + if (!accessControlUtil.isLLMOAdministrator()) { + return forbidden('Only LLMO administrators can access LLMO config'); + } + if (!await accessControlUtil.hasAccess(organization)) { + return forbidden('User does not have access to this organization'); + } + + if (!context.s3?.s3Client) { + return badRequest('S3 storage is not configured for this environment'); + } + + const customerConfig = await loadCustomerConfigFromS3(context, spaceCatId); + if (!customerConfig) { + return notFound('Customer configuration not found for organization'); + } + + const brand = findBrandOrNotFound(customerConfig, brandId); + if (brand.status) return brand; + + const { resolved, unresolved } = await resolveBrandUrlsToSites(brand.urls); + if (resolved.length === 0) { + return notFound('No brand URLs could be resolved to SpaceCat sites'); + } + if (unresolved.length > 0) { + log.warn(`Could not resolve ${unresolved.length} brand URL(s) to sites: ${unresolved.join(', ')}`); + } + + const configs = await Promise.all( + resolved.map(async ({ siteId, baseURL }) => { + try { + const { config, exists } = await readConfig(siteId, context.s3.s3Client, { + s3Bucket: context.s3.s3Bucket, + }); + return { siteId, baseURL, config: exists ? config : null }; + } catch (err) { + log.warn(`Failed to read v1 config for site ${siteId}: ${err.message}`); + return { siteId, baseURL, config: null }; + } + }), + ); + + return ok(configs); + } catch (error) { + log.error(`Error getting LLMO config for brand ${brandId} in org ${spaceCatId}`, error); + return createErrorResponse(error); + } + }; + + /** + * Updates v1 LLMO configs for specified sites in a brand, validating that each + * site belongs to the organization and brand. Delegates to the v1 + * updateLlmoConfig endpoint per site so the write pipeline is identical. + * @param {object} context - Context of the request. + * @returns {Promise} Per-site write results. + */ + const patchLlmoConfigForBrand = async (context) => { + const { spaceCatId, brandId } = context.params || {}; + + try { + if (!hasText(spaceCatId)) { + return badRequest('Organization ID required'); + } + if (!hasText(brandId)) { + return badRequest('Brand ID required'); + } + + const organization = await getOrganizationOrNotFound(spaceCatId); + if (organization.status) return organization; + + if (!accessControlUtil.isLLMOAdministrator()) { + return forbidden('Only LLMO administrators can update LLMO config'); + } + if (!await accessControlUtil.hasAccess(organization)) { + return forbidden('User does not have access to this organization'); + } + + if (!context.s3?.s3Client) { + return badRequest('S3 storage is not configured for this environment'); + } + + const entries = context.data; + if (!Array.isArray(entries) || entries.length === 0) { + return badRequest('Request body must be a non-empty array of {siteId, config} objects'); + } + + const customerConfig = await loadCustomerConfigFromS3(context, spaceCatId); + if (!customerConfig) { + return notFound('Customer configuration not found for organization'); + } + + const brand = findBrandOrNotFound(customerConfig, brandId); + if (brand.status) return brand; + + const normalizedBrandUrls = new Set( + (brand.urls || []).map((u) => composeBaseURL(u.value)), + ); + + const llmoController = LlmoController(ctx); + + const results = await Promise.all( + entries.map(async (entry) => { + const { siteId, config: configData } = entry; + try { + if (!hasText(siteId)) { + return { siteId, status: 'error', message: 'siteId is required' }; + } + + // Verify the site belongs to this org and brand before delegating. + // updateLlmoConfig handles site existence, LLMO enablement, and entitlement checks. + const site = await Site.findById(siteId); + if (!site) { + return { siteId, status: 'error', message: `Site not found: ${siteId}` }; + } + if (site.getOrganizationId() !== spaceCatId) { + return { siteId, status: 'error', message: 'Site does not belong to this organization' }; + } + const siteBaseURL = composeBaseURL(site.getBaseURL()); + if (!normalizedBrandUrls.has(siteBaseURL)) { + return { siteId, status: 'error', message: 'Site URL is not associated with this brand' }; + } + + const response = await llmoController.updateLlmoConfig({ + ...context, + params: { siteId }, + data: configData, + }); + + if (response.status === 200) { + const body = await response.json(); + return { siteId, status: 'success', version: body.version }; + } + + const errorBody = await response.json(); + return { + siteId, + status: 'error', + message: errorBody.message || `v1 update failed with status ${response.status}`, + }; + } catch (err) { + log.error(`Error writing v1 config for site ${siteId}: ${err.message}`); + return { + siteId, + status: 'error', + message: `Failed to write config: ${err.message}`, + }; + } + }), + ); + + return ok({ results }); + } catch (error) { + log.error(`Error patching LLMO config for brand ${brandId} in org ${spaceCatId}`, error); + return createErrorResponse(error); + } + }; + return { getBrandsForOrganization, getBrandGuidelinesForSite, @@ -646,6 +864,8 @@ function BrandsController(ctx, log, env) { getPrompts, saveCustomerConfig, patchCustomerConfig, + getLlmoConfigForBrand, + patchLlmoConfigForBrand, // Exported for testing filterByStatus, }; diff --git a/src/routes/index.js b/src/routes/index.js index 1b3cb9125..3fecce34b 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -163,6 +163,8 @@ export default function getRouteHandlers( 'GET /v2/orgs/:spaceCatId/llmo-prompts': brandsController.getPrompts, 'POST /v2/orgs/:spaceCatId/llmo-customer-config': brandsController.saveCustomerConfig, 'PATCH /v2/orgs/:spaceCatId/llmo-customer-config': brandsController.patchCustomerConfig, + 'GET /v2/orgs/:spaceCatId/brands/:brandId/llmo/config': brandsController.getLlmoConfigForBrand, + 'PATCH /v2/orgs/:spaceCatId/brands/:brandId/llmo/config': brandsController.patchLlmoConfigForBrand, 'GET /organizations/:organizationId/projects': organizationsController.getProjectsByOrganizationId, 'GET /organizations/:organizationId/projects/:projectId/sites': organizationsController.getSitesByProjectIdAndOrganizationId, 'GET /organizations/:organizationId/by-project-name/:projectName/sites': organizationsController.getSitesByProjectNameAndOrganizationId, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index bfb0cae68..1e5a1d057 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -123,6 +123,8 @@ const routeRequiredCapabilities = { 'GET /v2/orgs/:spaceCatId/llmo-prompts': 'organization:read', 'POST /v2/orgs/:spaceCatId/llmo-customer-config': 'organization:write', 'PATCH /v2/orgs/:spaceCatId/llmo-customer-config': 'organization:write', + 'GET /v2/orgs/:spaceCatId/brands/:brandId/llmo/config': 'organization:read', + 'PATCH /v2/orgs/:spaceCatId/brands/:brandId/llmo/config': 'organization:write', 'GET /organizations/:organizationId/projects': 'project:read', 'GET /organizations/:organizationId/projects/:projectId/sites': 'site:read', 'GET /organizations/:organizationId/by-project-name/:projectName/sites': 'site:read', diff --git a/test/controllers/brands.test.js b/test/controllers/brands.test.js index 6e86299aa..cd28e04e5 100644 --- a/test/controllers/brands.test.js +++ b/test/controllers/brands.test.js @@ -2662,6 +2662,1104 @@ describe('Brands Controller', () => { }); }); + describe('getLlmoConfigForBrand', () => { + const BRAND_ID_V2 = 'brand-1'; + const SPACE_CAT_ID = ORGANIZATION_ID; + + function createMockS3Context() { + return { + s3: { s3Client: {}, s3Bucket: 'test-bucket' }, + }; + } + + function createCustomerConfigWithBrand(brandId, urls = []) { + return { + customer: { + customerName: 'Adobe', + brands: [ + { + id: brandId, + name: 'Test Brand', + urls, + prompts: [], + }, + ], + }, + }; + } + + const llmoAdminAuthContext = { + attributes: { + authInfo: new AuthInfo() + .withType('jwt') + .withScopes([{ name: 'admin' }]) + .withProfile({ is_admin: true, is_llmo_administrator: true }) + .withAuthenticated(true), + }, + }; + + let llmoAdminContext; + let llmoAdminController; + + beforeEach(() => { + llmoAdminContext = { + pathInfo: { + headers: { + authorization: 'Bearer token123', + 'x-product': 'abcd', + }, + }, + dataAccess: mockDataAccess, + ...llmoAdminAuthContext, + }; + llmoAdminController = BrandsController(llmoAdminContext, loggerStub, mockEnv); + }); + + it('returns bad request if organization ID is missing', async () => { + const response = await llmoAdminController.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: {}, + ...createMockS3Context(), + }); + expect(response.status).to.equal(400); + }); + + it('returns bad request if brand ID is missing', async () => { + const response = await llmoAdminController.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID }, + ...createMockS3Context(), + }); + expect(response.status).to.equal(400); + }); + + it('returns not found if organization does not exist', async () => { + mockDataAccess.Organization.findById.resolves(null); + + const response = await llmoAdminController.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + expect(response.status).to.equal(404); + }); + + it('returns forbidden if user is not LLMO administrator', async () => { + const authContextUser = { + attributes: { + authInfo: new AuthInfo() + .withType('jwt') + .withScopes([{ name: 'user' }]) + .withProfile({ is_admin: false }) + .withAuthenticated(true), + }, + }; + const nonAdminController = BrandsController({ + dataAccess: mockDataAccess, + pathInfo: { headers: { 'x-product': 'llmo' } }, + ...authContextUser, + }, loggerStub, mockEnv); + + const response = await nonAdminController.getLlmoConfigForBrand({ + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + expect(response.status).to.equal(403); + }); + + it('returns forbidden if LLMO admin does not have access to organization', async () => { + const llmoOnlyAuth = { + attributes: { + authInfo: new AuthInfo() + .withType('jwt') + .withScopes([{ name: 'user' }]) + .withProfile({ is_admin: false, is_llmo_administrator: true }) + .withAuthenticated(true), + }, + }; + const llmoOnlyController = BrandsController({ + dataAccess: mockDataAccess, + pathInfo: { headers: { 'x-product': 'llmo' } }, + ...llmoOnlyAuth, + }, loggerStub, mockEnv); + + const response = await llmoOnlyController.getLlmoConfigForBrand({ + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + + expect(response.status).to.equal(403); + }); + + it('returns bad request if S3 is not configured', async () => { + const response = await llmoAdminController.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + s3: {}, + }); + expect(response.status).to.equal(400); + }); + + it('returns not found if customer config does not exist in S3', async () => { + const mockReadConfig = sinon.stub().resolves(null); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfig, + }, + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + expect(response.status).to.equal(404); + }); + + it('returns not found if brand does not exist in config', async () => { + const mockReadConfig = sinon.stub().resolves({ + customer: { + customerName: 'Adobe', + brands: [{ + id: 'other-brand', name: 'Other', urls: [], prompts: [], + }], + }, + }); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfig, + }, + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: 'nonexistent-brand' }, + ...createMockS3Context(), + }); + expect(response.status).to.equal(404); + }); + + it('returns not found if no brand URLs resolve to sites', async () => { + const customerConfig = createCustomerConfigWithBrand(BRAND_ID_V2, [ + { value: 'https://unknown.example.com' }, + ]); + + mockDataAccess.Site.findByBaseURL = sinon.stub().resolves(null); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + composeBaseURL: (url) => url, + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + expect(response.status).to.equal(404); + }); + + it('returns v1 configs for resolved brand URLs', async () => { + const siteObj = { + getId: () => SITE_ID, + getBaseURL: () => 'https://site1.com', + }; + const customerConfig = createCustomerConfigWithBrand(BRAND_ID_V2, [ + { value: 'https://site1.com' }, + ]); + + mockDataAccess.Site.findByBaseURL = sinon.stub().resolves(siteObj); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const mockReadConfig = sinon.stub().resolves({ + config: { some: 'v1-config' }, + exists: true, + }); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + readConfig: mockReadConfig, + }, + composeBaseURL: (url) => url, + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result).to.be.an('array').with.lengthOf(1); + expect(result[0].siteId).to.equal(SITE_ID); + expect(result[0].config).to.deep.equal({ some: 'v1-config' }); + }); + + it('returns null config when v1 config does not exist', async () => { + const siteObj = { + getId: () => SITE_ID, + getBaseURL: () => 'https://site1.com', + }; + const customerConfig = createCustomerConfigWithBrand(BRAND_ID_V2, [ + { value: 'https://site1.com' }, + ]); + + mockDataAccess.Site.findByBaseURL = sinon.stub().resolves(siteObj); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const mockReadConfig = sinon.stub().resolves({ + config: null, + exists: false, + }); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + readConfig: mockReadConfig, + }, + composeBaseURL: (url) => url, + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result[0].config).to.be.null; + }); + + it('returns null config when v1 readConfig throws', async () => { + const siteObj = { + getId: () => SITE_ID, + getBaseURL: () => 'https://site1.com', + }; + const customerConfig = createCustomerConfigWithBrand(BRAND_ID_V2, [ + { value: 'https://site1.com' }, + ]); + + mockDataAccess.Site.findByBaseURL = sinon.stub().resolves(siteObj); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const mockReadConfig = sinon.stub().rejects(new Error('S3 read failed')); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + readConfig: mockReadConfig, + }, + composeBaseURL: (url) => url, + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result[0].config).to.be.null; + }); + + it('logs warning for unresolved URLs', async () => { + const resolvedSite = { + getId: () => SITE_ID, + getBaseURL: () => 'https://resolved.com', + }; + const customerConfig = createCustomerConfigWithBrand(BRAND_ID_V2, [ + { value: 'https://resolved.com' }, + { value: 'https://unresolved.com' }, + ]); + + mockDataAccess.Site.findByBaseURL = sinon.stub(); + mockDataAccess.Site.findByBaseURL.withArgs('https://resolved.com').resolves(resolvedSite); + mockDataAccess.Site.findByBaseURL.withArgs('https://unresolved.com').resolves(null); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const mockReadConfig = sinon.stub().resolves({ config: {}, exists: true }); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + readConfig: mockReadConfig, + }, + composeBaseURL: (url) => url, + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + expect(loggerStub.warn).to.have.been.calledWithMatch(/Could not resolve 1 brand URL/); + }); + + it('handles error and returns error response', async () => { + const errorMockDataAccess = { + Organization: { + findById: sinon.stub().rejects(new Error('DB error')), + }, + Site: { findById: sinon.stub(), findByBaseURL: sinon.stub() }, + }; + + const errorContext = { ...llmoAdminContext, dataAccess: errorMockDataAccess }; + const errorBrandsController = BrandsController(errorContext, loggerStub, mockEnv); + + const response = await errorBrandsController.getLlmoConfigForBrand({ + ...errorContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + + expect(response.status).to.equal(500); + }); + + it('handles missing context.params gracefully', async () => { + const response = await llmoAdminController.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: undefined, + ...createMockS3Context(), + }); + expect(response.status).to.equal(400); + }); + + it('returns not found when brand has no urls property', async () => { + const customerConfig = { + customer: { + customerName: 'Adobe', + brands: [{ + id: BRAND_ID_V2, + name: 'Test Brand', + prompts: [], + }], + }, + }; + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + composeBaseURL: (url) => url, + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + + expect(response.status).to.equal(404); + const result = await response.json(); + expect(result.message).to.include('No brand URLs could be resolved'); + }); + + it('handles brand config with null brands array', async () => { + const mockReadConfig = sinon.stub().resolves({ + customer: { + customerName: 'Adobe', + brands: null, + }, + }); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfig, + }, + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.getLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + ...createMockS3Context(), + }); + expect(response.status).to.equal(404); + }); + }); + + describe('patchLlmoConfigForBrand', () => { + const BRAND_ID_V2 = 'brand-1'; + const SPACE_CAT_ID = ORGANIZATION_ID; + + const llmoAdminAuthContext = { + attributes: { + authInfo: new AuthInfo() + .withType('jwt') + .withScopes([{ name: 'admin' }]) + .withProfile({ is_admin: true, is_llmo_administrator: true }) + .withAuthenticated(true), + }, + }; + + let llmoAdminContext; + let llmoAdminController; + + beforeEach(() => { + llmoAdminContext = { + pathInfo: { + headers: { + authorization: 'Bearer token123', + 'x-product': 'abcd', + }, + }, + dataAccess: mockDataAccess, + ...llmoAdminAuthContext, + }; + llmoAdminController = BrandsController(llmoAdminContext, loggerStub, mockEnv); + }); + + function createMockS3Context() { + return { + s3: { s3Client: {}, s3Bucket: 'test-bucket' }, + }; + } + + it('returns bad request if organization ID is missing', async () => { + const response = await llmoAdminController.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: {}, + data: [], + ...createMockS3Context(), + }); + expect(response.status).to.equal(400); + }); + + it('returns bad request if brand ID is missing', async () => { + const response = await llmoAdminController.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID }, + data: [], + ...createMockS3Context(), + }); + expect(response.status).to.equal(400); + }); + + it('returns not found if organization does not exist', async () => { + mockDataAccess.Organization.findById.resolves(null); + + const response = await llmoAdminController.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + expect(response.status).to.equal(404); + }); + + it('returns forbidden if user is not LLMO administrator', async () => { + const authContextUser = { + attributes: { + authInfo: new AuthInfo() + .withType('jwt') + .withScopes([{ name: 'user' }]) + .withProfile({ is_admin: false }) + .withAuthenticated(true), + }, + }; + const nonAdminController = BrandsController({ + dataAccess: mockDataAccess, + pathInfo: { headers: { 'x-product': 'llmo' } }, + ...authContextUser, + }, loggerStub, mockEnv); + + const response = await nonAdminController.patchLlmoConfigForBrand({ + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + expect(response.status).to.equal(403); + }); + + it('returns forbidden if LLMO admin does not have access to organization', async () => { + const llmoOnlyAuth = { + attributes: { + authInfo: new AuthInfo() + .withType('jwt') + .withScopes([{ name: 'user' }]) + .withProfile({ is_admin: false, is_llmo_administrator: true }) + .withAuthenticated(true), + }, + }; + const llmoOnlyController = BrandsController({ + dataAccess: mockDataAccess, + pathInfo: { headers: { 'x-product': 'llmo' } }, + ...llmoOnlyAuth, + }, loggerStub, mockEnv); + + const response = await llmoOnlyController.patchLlmoConfigForBrand({ + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + expect(response.status).to.equal(403); + }); + + it('returns bad request if S3 is not configured', async () => { + const response = await llmoAdminController.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + s3: {}, + }); + expect(response.status).to.equal(400); + }); + + it('returns bad request if data is not a non-empty array', async () => { + const response = await llmoAdminController.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [], + ...createMockS3Context(), + }); + expect(response.status).to.equal(400); + }); + + it('returns bad request if data is not an array', async () => { + const response = await llmoAdminController.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: { siteId: SITE_ID, config: {} }, + ...createMockS3Context(), + }); + expect(response.status).to.equal(400); + }); + + it('returns not found if customer config does not exist', async () => { + const mockReadConfigV2 = sinon.stub().resolves(null); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + expect(response.status).to.equal(404); + }); + + it('returns not found if brand does not exist in config', async () => { + const mockReadConfigV2 = sinon.stub().resolves({ + customer: { + customerName: 'Adobe', + brands: [{ + id: 'other-brand', name: 'Other', urls: [], prompts: [], + }], + }, + }); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: 'nonexistent-brand' }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + expect(response.status).to.equal(404); + }); + + it('returns error for entry with missing siteId', async () => { + const customerConfig = { + customer: { + customerName: 'Adobe', + brands: [{ + id: BRAND_ID_V2, + name: 'Test Brand', + urls: [{ value: 'https://site1.com' }], + prompts: [], + }], + }, + }; + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const mockUpdateLlmoConfig = sinon.stub().resolves( + new Response(JSON.stringify({ version: 2 }), { status: 200 }), + ); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + composeBaseURL: (url) => url, + }, + '../../src/controllers/llmo/llmo.js': { + default: () => ({ updateLlmoConfig: mockUpdateLlmoConfig }), + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ config: {} }], + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result.results[0].status).to.equal('error'); + expect(result.results[0].message).to.equal('siteId is required'); + }); + + it('returns error if site not found', async () => { + const customerConfig = { + customer: { + customerName: 'Adobe', + brands: [{ + id: BRAND_ID_V2, + name: 'Test Brand', + urls: [{ value: 'https://site1.com' }], + prompts: [], + }], + }, + }; + + mockDataAccess.Site.findById.resolves(null); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + composeBaseURL: (url) => url, + }, + '../../src/controllers/llmo/llmo.js': { + default: () => ({ updateLlmoConfig: sinon.stub() }), + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result.results[0].status).to.equal('error'); + expect(result.results[0].message).to.include('Site not found'); + }); + + it('returns error if site does not belong to organization', async () => { + const OTHER_ORG_ID = '9999554c-de8a-44ac-a356-09b51af8cc28'; + const customerConfig = { + customer: { + customerName: 'Adobe', + brands: [{ + id: BRAND_ID_V2, + name: 'Test Brand', + urls: [{ value: 'https://site1.com' }], + prompts: [], + }], + }, + }; + + const siteInOtherOrg = { + getId: () => SITE_ID, + getOrganizationId: () => OTHER_ORG_ID, + getBaseURL: () => 'https://site1.com', + }; + mockDataAccess.Site.findById.resolves(siteInOtherOrg); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + composeBaseURL: (url) => url, + }, + '../../src/controllers/llmo/llmo.js': { + default: () => ({ updateLlmoConfig: sinon.stub() }), + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result.results[0].status).to.equal('error'); + expect(result.results[0].message).to.include('does not belong to this organization'); + }); + + it('returns error if site URL is not associated with brand', async () => { + const customerConfig = { + customer: { + customerName: 'Adobe', + brands: [{ + id: BRAND_ID_V2, + name: 'Test Brand', + urls: [{ value: 'https://brand-site.com' }], + prompts: [], + }], + }, + }; + + const siteWithDifferentUrl = { + getId: () => SITE_ID, + getOrganizationId: () => SPACE_CAT_ID, + getBaseURL: () => 'https://other-site.com', + }; + mockDataAccess.Site.findById.resolves(siteWithDifferentUrl); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + composeBaseURL: (url) => url, + }, + '../../src/controllers/llmo/llmo.js': { + default: () => ({ updateLlmoConfig: sinon.stub() }), + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result.results[0].status).to.equal('error'); + expect(result.results[0].message).to.include('not associated with this brand'); + }); + + it('successfully delegates to updateLlmoConfig and returns results', async () => { + const customerConfig = { + customer: { + customerName: 'Adobe', + brands: [{ + id: BRAND_ID_V2, + name: 'Test Brand', + urls: [{ value: 'https://site1.com' }], + prompts: [], + }], + }, + }; + + const siteObj = { + getId: () => SITE_ID, + getOrganizationId: () => SPACE_CAT_ID, + getBaseURL: () => 'https://site1.com', + }; + mockDataAccess.Site.findById.resolves(siteObj); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const mockUpdateLlmoConfig = sinon.stub().resolves( + new Response(JSON.stringify({ version: 3 }), { status: 200 }), + ); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + composeBaseURL: (url) => url, + }, + '../../src/controllers/llmo/llmo.js': { + default: () => ({ updateLlmoConfig: mockUpdateLlmoConfig }), + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: { some: 'v1-data' } }], + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result.results).to.have.lengthOf(1); + expect(result.results[0].status).to.equal('success'); + expect(result.results[0].version).to.equal(3); + }); + + it('handles updateLlmoConfig returning non-200 status', async () => { + const customerConfig = { + customer: { + customerName: 'Adobe', + brands: [{ + id: BRAND_ID_V2, + name: 'Test Brand', + urls: [{ value: 'https://site1.com' }], + prompts: [], + }], + }, + }; + + const siteObj = { + getId: () => SITE_ID, + getOrganizationId: () => SPACE_CAT_ID, + getBaseURL: () => 'https://site1.com', + }; + mockDataAccess.Site.findById.resolves(siteObj); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const mockUpdateLlmoConfig = sinon.stub().resolves( + new Response(JSON.stringify({ message: 'LLMO not enabled' }), { status: 400 }), + ); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + composeBaseURL: (url) => url, + }, + '../../src/controllers/llmo/llmo.js': { + default: () => ({ updateLlmoConfig: mockUpdateLlmoConfig }), + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result.results[0].status).to.equal('error'); + expect(result.results[0].message).to.equal('LLMO not enabled'); + }); + + it('handles updateLlmoConfig throwing an error', async () => { + const customerConfig = { + customer: { + customerName: 'Adobe', + brands: [{ + id: BRAND_ID_V2, + name: 'Test Brand', + urls: [{ value: 'https://site1.com' }], + prompts: [], + }], + }, + }; + + const siteObj = { + getId: () => SITE_ID, + getOrganizationId: () => SPACE_CAT_ID, + getBaseURL: () => 'https://site1.com', + }; + mockDataAccess.Site.findById.resolves(siteObj); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const mockUpdateLlmoConfig = sinon.stub().rejects(new Error('Network timeout')); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + composeBaseURL: (url) => url, + }, + '../../src/controllers/llmo/llmo.js': { + default: () => ({ updateLlmoConfig: mockUpdateLlmoConfig }), + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result.results[0].status).to.equal('error'); + expect(result.results[0].message).to.include('Network timeout'); + }); + + it('handles brand with no urls', async () => { + const customerConfig = { + customer: { + customerName: 'Adobe', + brands: [{ + id: BRAND_ID_V2, + name: 'Test Brand', + prompts: [], + }], + }, + }; + + const siteObj = { + getId: () => SITE_ID, + getOrganizationId: () => SPACE_CAT_ID, + getBaseURL: () => 'https://site1.com', + }; + mockDataAccess.Site.findById.resolves(siteObj); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + composeBaseURL: (url) => url, + }, + '../../src/controllers/llmo/llmo.js': { + default: () => ({ updateLlmoConfig: sinon.stub() }), + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result.results[0].status).to.equal('error'); + expect(result.results[0].message).to.include('not associated with this brand'); + }); + + it('handles error and returns error response', async () => { + const errorMockDataAccess = { + Organization: { + findById: sinon.stub().rejects(new Error('DB error')), + }, + Site: { findById: sinon.stub() }, + }; + + const errorContext = { ...llmoAdminContext, dataAccess: errorMockDataAccess }; + const errorBrandsController = BrandsController(errorContext, loggerStub, mockEnv); + + const response = await errorBrandsController.patchLlmoConfigForBrand({ + ...errorContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + + expect(response.status).to.equal(500); + }); + + it('handles missing context.params gracefully', async () => { + const response = await llmoAdminController.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: undefined, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + expect(response.status).to.equal(400); + }); + + it('handles non-200 response without message in body', async () => { + const customerConfig = { + customer: { + customerName: 'Adobe', + brands: [{ + id: BRAND_ID_V2, + name: 'Test Brand', + urls: [{ value: 'https://site1.com' }], + prompts: [], + }], + }, + }; + + const siteObj = { + getId: () => SITE_ID, + getOrganizationId: () => SPACE_CAT_ID, + getBaseURL: () => 'https://site1.com', + }; + mockDataAccess.Site.findById.resolves(siteObj); + + const mockReadConfigV2 = sinon.stub().resolves(customerConfig); + const mockUpdateLlmoConfig = sinon.stub().resolves( + new Response(JSON.stringify({}), { status: 500 }), + ); + const brandsControllerWithMock = await esmock('../../src/controllers/brands.js', { + '@adobe/spacecat-shared-utils': { + llmoConfig: { + readCustomerConfigV2: mockReadConfigV2, + }, + composeBaseURL: (url) => url, + }, + '../../src/controllers/llmo/llmo.js': { + default: () => ({ updateLlmoConfig: mockUpdateLlmoConfig }), + }, + }); + + const controller = brandsControllerWithMock(llmoAdminContext, loggerStub, mockEnv); + const response = await controller.patchLlmoConfigForBrand({ + ...llmoAdminContext, + params: { spaceCatId: SPACE_CAT_ID, brandId: BRAND_ID_V2 }, + data: [{ siteId: SITE_ID, config: {} }], + ...createMockS3Context(), + }); + + expect(response.status).to.equal(200); + const result = await response.json(); + expect(result.results[0].status).to.equal('error'); + expect(result.results[0].message).to.include('v1 update failed with status 500'); + }); + }); + describe('filterByStatus (exported for testing)', () => { it('should return empty array when items is null', () => { const controller = BrandsController(context, loggerStub, mockEnv); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 0c3cba945..d48879647 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -156,6 +156,12 @@ describe('getRouteHandlers', () => { getBrandGuidelinesForSite: sinon.stub(), getCustomerConfig: sinon.stub(), saveCustomerConfig: sinon.stub(), + getCustomerConfigLean: sinon.stub(), + getTopics: sinon.stub(), + getPrompts: sinon.stub(), + patchCustomerConfig: sinon.stub(), + getLlmoConfigForBrand: sinon.stub(), + patchLlmoConfigForBrand: sinon.stub(), }; const mockPreflightController = { @@ -464,6 +470,8 @@ describe('getRouteHandlers', () => { 'GET /v2/orgs/:spaceCatId/llmo-prompts', 'PATCH /v2/orgs/:spaceCatId/llmo-customer-config', 'POST /v2/orgs/:spaceCatId/llmo-customer-config', + 'GET /v2/orgs/:spaceCatId/brands/:brandId/llmo/config', + 'PATCH /v2/orgs/:spaceCatId/brands/:brandId/llmo/config', 'GET /organizations/:organizationId/projects', 'GET /organizations/:organizationId/projects/:projectId/sites', 'GET /organizations/:organizationId/by-project-name/:projectName/sites', @@ -940,5 +948,9 @@ describe('getRouteHandlers', () => { expect(dynamicRoutes['PATCH /sites/:siteId/url-store'].paramNames).to.deep.equal(['siteId']); expect(dynamicRoutes['POST /sites/:siteId/url-store/delete'].handler).to.equal(mockUrlStoreController.deleteUrls); expect(dynamicRoutes['POST /sites/:siteId/url-store/delete'].paramNames).to.deep.equal(['siteId']); + expect(dynamicRoutes['GET /v2/orgs/:spaceCatId/brands/:brandId/llmo/config'].handler).to.equal(mockBrandsController.getLlmoConfigForBrand); + expect(dynamicRoutes['GET /v2/orgs/:spaceCatId/brands/:brandId/llmo/config'].paramNames).to.deep.equal(['spaceCatId', 'brandId']); + expect(dynamicRoutes['PATCH /v2/orgs/:spaceCatId/brands/:brandId/llmo/config'].handler).to.equal(mockBrandsController.patchLlmoConfigForBrand); + expect(dynamicRoutes['PATCH /v2/orgs/:spaceCatId/brands/:brandId/llmo/config'].paramNames).to.deep.equal(['spaceCatId', 'brandId']); }); }); From 700bbb43dd10948b16051b5ddc8b0fde068dd98c Mon Sep 17 00:00:00 2001 From: Igor Grubic Date: Wed, 11 Mar 2026 14:43:15 +0100 Subject: [PATCH 2/5] Trigger deployment From bef07a73f3549909a0eb655747159912b97fd41d Mon Sep 17 00:00:00 2001 From: Igor Grubic Date: Wed, 11 Mar 2026 14:55:23 +0100 Subject: [PATCH 3/5] Trigger deployment From a42d3f4c69187c1b1785ffcb17a98499239f6537 Mon Sep 17 00:00:00 2001 From: Igor Grubic Date: Thu, 12 Mar 2026 12:46:17 +0100 Subject: [PATCH 4/5] adds fixes --- src/controllers/brands.js | 21 ++- src/support/customer-config-v2-metadata.js | 34 +++- test/controllers/brands.test.js | 15 +- .../customer-config-v2-metadata.test.js | 157 ++++++++++++++++++ 4 files changed, 211 insertions(+), 16 deletions(-) diff --git a/src/controllers/brands.js b/src/controllers/brands.js index f72b8cea9..6390edd85 100644 --- a/src/controllers/brands.js +++ b/src/controllers/brands.js @@ -652,9 +652,9 @@ function BrandsController(ctx, log, env) { const brands = customerConfig?.customer?.brands || []; const brand = brands.find((b) => b.id === brandId); if (!brand) { - return notFound(`Brand not found: ${brandId}`); + return { notFoundResponse: notFound(`Brand not found: ${brandId}`) }; } - return brand; + return { brand }; } /** @@ -664,10 +664,12 @@ function BrandsController(ctx, log, env) { * @returns {Promise<{resolved: Array, unresolved: Array}>} */ async function resolveBrandUrlsToSites(urls) { + log.info(`resolveBrandUrlsToSites: input urls = ${JSON.stringify(urls)}`); const results = await Promise.all( (urls || []).map(async (urlEntry) => { const normalized = composeBaseURL(urlEntry.value); const site = await Site.findByBaseURL(normalized); + log.info(`resolveBrandUrlsToSites: urlEntry.value="${urlEntry.value}" -> normalized="${normalized}" -> site=${site ? site.getId() : 'null'}`); return { url: urlEntry.value, normalized, site }; }), ); @@ -677,6 +679,7 @@ function BrandsController(ctx, log, env) { const unresolved = results .filter((r) => !r.site) .map((r) => r.url); + log.info(`resolveBrandUrlsToSites: resolved=${resolved.length}, unresolved=${unresolved.length} (${unresolved.join(', ')})`); return { resolved, unresolved }; } @@ -715,8 +718,9 @@ function BrandsController(ctx, log, env) { return notFound('Customer configuration not found for organization'); } - const brand = findBrandOrNotFound(customerConfig, brandId); - if (brand.status) return brand; + const brandResult = findBrandOrNotFound(customerConfig, brandId); + if (brandResult.notFoundResponse) return brandResult.notFoundResponse; + const { brand } = brandResult; const { resolved, unresolved } = await resolveBrandUrlsToSites(brand.urls); if (resolved.length === 0) { @@ -740,7 +744,9 @@ function BrandsController(ctx, log, env) { }), ); - return ok(configs); + return ok(configs, { + 'Content-Encoding': 'br', + }); } catch (error) { log.error(`Error getting LLMO config for brand ${brandId} in org ${spaceCatId}`, error); return createErrorResponse(error); @@ -789,8 +795,9 @@ function BrandsController(ctx, log, env) { return notFound('Customer configuration not found for organization'); } - const brand = findBrandOrNotFound(customerConfig, brandId); - if (brand.status) return brand; + const brandResult = findBrandOrNotFound(customerConfig, brandId); + if (brandResult.notFoundResponse) return brandResult.notFoundResponse; + const { brand } = brandResult; const normalizedBrandUrls = new Set( (brand.urls || []).map((u) => composeBaseURL(u.value)), diff --git a/src/support/customer-config-v2-metadata.js b/src/support/customer-config-v2-metadata.js index 1d87a7a7f..a614b806d 100644 --- a/src/support/customer-config-v2-metadata.js +++ b/src/support/customer-config-v2-metadata.js @@ -15,16 +15,38 @@ import { deepEqual } from '@adobe/spacecat-shared-utils'; /** - * Removes metadata fields (updatedBy, updatedAt, status) from an object. + * Fields to strip when comparing objects for changes. Includes metadata fields + * (updatedBy, updatedAt, status) and enrichment/computed fields that are added + * by GET endpoints (getPrompts, getCustomerConfigLean) but not stored in S3. + */ +const STRIP_FIELDS = new Set([ + 'updatedAt', + 'updatedBy', + 'status', + // Enrichment fields added by getPrompts + 'brandId', + 'brandName', + 'category', + 'topic', + // Computed fields added by getCustomerConfigLean + 'totalCategories', + 'totalTopics', + 'totalPrompts', +]); + +/** + * Removes metadata and enrichment fields from an object for comparison purposes. * @param {object} obj - The object to clean. - * @returns {object} A new object without metadata fields. + * @returns {object} A new object without metadata/enrichment fields. */ const stripMetadata = (obj) => { if (!obj || typeof obj !== 'object') return obj; - const { - // eslint-disable-next-line no-unused-vars - updatedAt, updatedBy, status, ...rest - } = obj; + const rest = {}; + for (const [key, value] of Object.entries(obj)) { + if (!STRIP_FIELDS.has(key)) { + rest[key] = value; + } + } return rest; }; diff --git a/test/controllers/brands.test.js b/test/controllers/brands.test.js index cd28e04e5..814e15445 100644 --- a/test/controllers/brands.test.js +++ b/test/controllers/brands.test.js @@ -18,6 +18,7 @@ import OrganizationSchema from '@adobe/spacecat-shared-data-access/src/models/or import SiteSchema from '@adobe/spacecat-shared-data-access/src/models/site/site.schema.js'; import AuthInfo from '@adobe/spacecat-shared-http-utils/src/auth/auth-info.js'; +import { brotliDecompressSync } from 'zlib'; import { use, expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import sinonChai from 'sinon-chai'; @@ -26,6 +27,11 @@ import esmock from 'esmock'; import BrandsController from '../../src/controllers/brands.js'; +async function readCompressedJson(response) { + const buf = await response.buffer(); + return JSON.parse(brotliDecompressSync(buf).toString()); +} + use(chaiAsPromised); use(sinonChai); @@ -2905,7 +2911,8 @@ describe('Brands Controller', () => { }); expect(response.status).to.equal(200); - const result = await response.json(); + expect(response.headers.get('Content-Encoding')).to.equal('br'); + const result = await readCompressedJson(response); expect(result).to.be.an('array').with.lengthOf(1); expect(result[0].siteId).to.equal(SITE_ID); expect(result[0].config).to.deep.equal({ some: 'v1-config' }); @@ -2945,7 +2952,8 @@ describe('Brands Controller', () => { }); expect(response.status).to.equal(200); - const result = await response.json(); + expect(response.headers.get('Content-Encoding')).to.equal('br'); + const result = await readCompressedJson(response); expect(result[0].config).to.be.null; }); @@ -2980,7 +2988,8 @@ describe('Brands Controller', () => { }); expect(response.status).to.equal(200); - const result = await response.json(); + expect(response.headers.get('Content-Encoding')).to.equal('br'); + const result = await readCompressedJson(response); expect(result[0].config).to.be.null; }); diff --git a/test/support/customer-config-v2-metadata.test.js b/test/support/customer-config-v2-metadata.test.js index 2cf2515b7..0db55073e 100644 --- a/test/support/customer-config-v2-metadata.test.js +++ b/test/support/customer-config-v2-metadata.test.js @@ -1699,6 +1699,129 @@ describe('Customer Config V2 Metadata', () => { expect(stats.prompts.modified).to.equal(1); }); + it('should preserve prompt metadata when prompts have enrichment fields from getPrompts', () => { + const existingConfig = { + customer: { + customerName: 'Test Customer', + imsOrgID: 'TEST123@AdobeOrg', + categories: [ + { + id: 'cat-1', name: 'Adobe', status: 'active', origin: 'system', + }, + ], + topics: [ + { + id: 'topic-1', name: 'Existing Topic', categoryId: 'cat-1', status: 'active', + }, + ], + brands: [ + { + id: 'brand-1', + name: 'helpx', + status: 'active', + updatedBy: 'old-user@example.com', + updatedAt: '2026-01-01T00:00:00.000Z', + prompts: [ + { + id: 'p1', + prompt: 'What is Firefly?', + categoryId: 'cat-1', + topicId: 'topic-1', + regions: ['US'], + status: 'active', + updatedBy: 'old-user@example.com', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'p2', + prompt: 'How does Acrobat work?', + categoryId: 'cat-1', + topicId: 'topic-1', + regions: ['US'], + status: 'active', + updatedBy: 'old-user@example.com', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }, + ], + }, + }; + + // UI sends back prompts with enrichment fields from getPrompts + a new topic added + const updates = { + customer: { + categories: [ + { + id: 'cat-1', name: 'Adobe', status: 'active', origin: 'system', + }, + ], + topics: [ + { + id: 'topic-1', name: 'Existing Topic', categoryId: 'cat-1', status: 'active', + }, + { + id: 'topic-2', name: '01 IG Topic', categoryId: 'cat-1', status: 'active', + }, + ], + brands: [ + { + id: 'brand-1', + name: 'helpx', + status: 'active', + prompts: [ + { + id: 'p1', + prompt: 'What is Firefly?', + categoryId: 'cat-1', + topicId: 'topic-1', + regions: ['US'], + status: 'active', + brandId: 'brand-1', + brandName: 'helpx', + category: { id: 'cat-1', name: 'Adobe', origin: 'system' }, + topic: { id: 'topic-1', name: 'Existing Topic', categoryId: 'cat-1' }, + }, + { + id: 'p2', + prompt: 'How does Acrobat work?', + categoryId: 'cat-1', + topicId: 'topic-1', + regions: ['US'], + status: 'active', + brandId: 'brand-1', + brandName: 'helpx', + category: { id: 'cat-1', name: 'Adobe', origin: 'system' }, + topic: { id: 'topic-1', name: 'Existing Topic', categoryId: 'cat-1' }, + }, + { + id: 'p3', + prompt: 'New prompt under new topic', + categoryId: 'cat-1', + topicId: 'topic-2', + regions: ['AU'], + status: 'active', + }, + ], + }, + ], + }, + }; + + const { mergedConfig, stats } = mergeCustomerConfigV2(updates, existingConfig, userId); + + // Existing prompts should preserve their metadata (enrichment fields stripped during compare) + expect(mergedConfig.customer.brands[0].prompts[0].updatedBy).to.equal('old-user@example.com'); + expect(mergedConfig.customer.brands[0].prompts[0].updatedAt).to.equal('2026-01-01T00:00:00.000Z'); + expect(mergedConfig.customer.brands[0].prompts[1].updatedBy).to.equal('old-user@example.com'); + expect(mergedConfig.customer.brands[0].prompts[1].updatedAt).to.equal('2026-01-01T00:00:00.000Z'); + + // Only the new prompt and new topic should be marked as modified + expect(mergedConfig.customer.brands[0].prompts[2].updatedBy).to.equal(userId); + expect(stats.prompts.modified).to.equal(1); + expect(stats.topics.modified).to.equal(1); + }); + it('should handle undefined oldBrands triggering || [] fallback', () => { const existingConfig = { customer: { @@ -1756,5 +1879,39 @@ describe('Customer Config V2 Metadata', () => { const result = stripMetadata(obj); expect(result).to.deep.equal({ id: 'test-id', name: 'Test' }); }); + + it('should strip enrichment fields added by getPrompts', async () => { + const { stripMetadata } = await import('../../src/support/customer-config-v2-metadata.js'); + const obj = { + id: 'prompt-1', + prompt: 'What is this?', + categoryId: 'cat-1', + topicId: 'topic-1', + brandId: 'brand-1', + brandName: 'Test Brand', + category: { id: 'cat-1', name: 'Category 1', origin: 'system' }, + topic: { id: 'topic-1', name: 'Topic 1', categoryId: 'cat-1' }, + }; + const result = stripMetadata(obj); + expect(result).to.deep.equal({ + id: 'prompt-1', + prompt: 'What is this?', + categoryId: 'cat-1', + topicId: 'topic-1', + }); + }); + + it('should strip computed fields added by getCustomerConfigLean', async () => { + const { stripMetadata } = await import('../../src/support/customer-config-v2-metadata.js'); + const obj = { + id: 'brand-1', + name: 'Brand One', + totalCategories: 5, + totalTopics: 10, + totalPrompts: 20, + }; + const result = stripMetadata(obj); + expect(result).to.deep.equal({ id: 'brand-1', name: 'Brand One' }); + }); }); }); From 99bc8813d5f2dd93915994cffc804c95152c9357 Mon Sep 17 00:00:00 2001 From: Igor Grubic Date: Thu, 12 Mar 2026 13:20:55 +0100 Subject: [PATCH 5/5] review --- src/controllers/brands.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/controllers/brands.js b/src/controllers/brands.js index 6390edd85..5c5bcc1f3 100644 --- a/src/controllers/brands.js +++ b/src/controllers/brands.js @@ -23,7 +23,6 @@ import { isNonEmptyObject, isValidUUID, llmoConfig, - llmoConfig as llmo, composeBaseURL, } from '@adobe/spacecat-shared-utils'; @@ -640,7 +639,7 @@ function BrandsController(ctx, log, env) { } }; - const { readConfig } = llmo; + const { readConfig } = llmoConfig; /** * Finds a brand by ID within a customer config. Returns the brand or a notFound response. @@ -664,12 +663,10 @@ function BrandsController(ctx, log, env) { * @returns {Promise<{resolved: Array, unresolved: Array}>} */ async function resolveBrandUrlsToSites(urls) { - log.info(`resolveBrandUrlsToSites: input urls = ${JSON.stringify(urls)}`); const results = await Promise.all( (urls || []).map(async (urlEntry) => { const normalized = composeBaseURL(urlEntry.value); const site = await Site.findByBaseURL(normalized); - log.info(`resolveBrandUrlsToSites: urlEntry.value="${urlEntry.value}" -> normalized="${normalized}" -> site=${site ? site.getId() : 'null'}`); return { url: urlEntry.value, normalized, site }; }), ); @@ -679,7 +676,7 @@ function BrandsController(ctx, log, env) { const unresolved = results .filter((r) => !r.site) .map((r) => r.url); - log.info(`resolveBrandUrlsToSites: resolved=${resolved.length}, unresolved=${unresolved.length} (${unresolved.join(', ')})`); + log.debug(`resolveBrandUrlsToSites: resolved=${resolved.length}, unresolved=${unresolved.length}`); return { resolved, unresolved }; }