diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index c30a9929ce..b5fc8cde3b 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -130,6 +130,8 @@ paths: $ref: './brands-v2-api.yaml#/v2-brands-for-org' /v2/orgs/{spaceCatId}/brands/{brandId}: $ref: './brands-v2-api.yaml#/v2-brand-for-org' + /v2/orgs/{spaceCatId}/brands/{brandId}/status: + $ref: './brands-v2-api.yaml#/v2-brand-status-for-org' /v2/orgs/{spaceCatId}/sites/{siteId}/brand: $ref: './brands-v2-api.yaml#/v2-brand-for-org-site' /v2/orgs/{spaceCatId}/categories: diff --git a/docs/openapi/brands-v2-api.yaml b/docs/openapi/brands-v2-api.yaml index e4050c5b7a..b78e9496cd 100644 --- a/docs/openapi/brands-v2-api.yaml +++ b/docs/openapi/brands-v2-api.yaml @@ -199,6 +199,70 @@ v2-brand-for-org: '503': description: PostgREST unavailable (DATA_SERVICE_PROVIDER=postgres required) +v2-brand-status-for-org: + parameters: + - name: spaceCatId + in: path + required: true + description: SpaceCat Organization ID (UUID) + schema: + type: string + format: uuid + - name: brandId + in: path + required: true + description: Brand ID (UUID) + schema: + type: string + format: uuid + patch: + tags: + - brands + - customer-config + summary: Transition a brand's status (v2) + description: | + Explicitly transitions a brand's lifecycle status (approve -> `active`, + move-to-pending -> `pending`). This is the sanctioned path for an + active -> pending demotion. Once the companion demotion guard ships + (LLMO-5587 PR3), the generic + `PATCH /v2/orgs/{spaceCatId}/brands/{brandId}` will refuse that transition. + operationId: transitionBrandStatusForOrgV2 + security: + - ims_key: [] + - api_key: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - status + properties: + status: + type: string + enum: + - active + - pending + description: Target brand status. + responses: + '200': + description: Brand status updated successfully + content: + application/json: + schema: + $ref: './schemas.yaml#/V2Brand' + '400': + $ref: './responses.yaml#/400' + '403': + description: User does not have access to this organization + '404': + description: Organization or brand not found + '500': + $ref: './responses.yaml#/500' + '503': + description: PostgREST unavailable (DATA_SERVICE_PROVIDER=postgres required) + v2-brand-for-org-site: parameters: - name: spaceCatId diff --git a/src/controllers/brands.js b/src/controllers/brands.js index 1b0b8669ad..f0daf181fb 100644 --- a/src/controllers/brands.js +++ b/src/controllers/brands.js @@ -49,6 +49,7 @@ import { upsertBrand, updateBrand, deleteBrand, + setBrandStatus, getBrandById, getBrandBySite, getBrandCompetitors, @@ -1969,6 +1970,66 @@ function BrandsController(ctx, log, env) { } }; + // Explicit, intentful brand status transition (approve -> active, move-to-pending -> + // pending). This is the sanctioned path for an active->pending demotion: the generic + // PATCH /brands/:brandId refuses that transition (LLMO-5587), routing intent here. + const transitionBrandStatusForOrg = async (context) => { + const { spaceCatId, brandId } = context.params || {}; + const { status } = context.data || {}; + + try { + if (!hasText(spaceCatId)) { + return badRequest('Organization ID required'); + } + if (!isValidUUID(spaceCatId)) { + return badRequest('Organization ID must be a valid UUID'); + } + if (!hasText(brandId)) { + return badRequest('Brand ID required'); + } + if (status !== 'active' && status !== 'pending') { + return badRequest("status must be one of 'active' or 'pending'"); + } + + const organization = await getOrganizationOrNotFound(spaceCatId); + if (organization.status) { + return organization; + } + if (!await accessControlUtil.hasAccess(organization)) { + return forbidden('User does not have access to this organization'); + } + + const unavailable = requirePostgrestForV2Config(context); + if (unavailable) { + return unavailable; + } + + const { postgrestClient } = context.dataAccess.services; + const updatedBy = context.attributes?.authInfo?.profile?.email || 'system'; + + const brandUuid = await resolveBrandUuid(spaceCatId, brandId, postgrestClient); + if (!brandUuid) { + return notFound(`Brand not found: ${brandId}`); + } + + const updated = await setBrandStatus({ + organizationId: spaceCatId, + brandId: brandUuid, + status, + postgrestClient, + updatedBy, + }); + + if (!updated) { + return notFound(`Brand not found: ${brandId}`); + } + return ok(updated); + } catch (error) { + log.error(`Error transitioning status for brand ${brandId} in organization ${spaceCatId}:`, error); + return createErrorResponse(error); + } + }; + return { getBrandsForOrganization, getBrandGuidelinesForSite, @@ -1986,6 +2047,7 @@ function BrandsController(ctx, log, env) { createBrandForOrg, updateBrandForOrg, deleteBrandForOrg, + transitionBrandStatusForOrg, listPromptsByBrand, getPromptByBrandAndId, getPromptStatsByBrand, diff --git a/src/routes/facs-capabilities.js b/src/routes/facs-capabilities.js index 2463c77a48..c7d04d796d 100644 --- a/src/routes/facs-capabilities.js +++ b/src/routes/facs-capabilities.js @@ -512,6 +512,7 @@ const routeFacsCapabilities = { 'PATCH /tools/import/jobs/:jobId': 'llmo/can_configure', 'PATCH /trial-users/email-preferences': 'llmo/can_configure', 'PATCH /v2/orgs/:spaceCatId/brands/:brandId': 'llmo/can_configure', + 'PATCH /v2/orgs/:spaceCatId/brands/:brandId/status': 'llmo/can_configure', 'PATCH /v2/orgs/:spaceCatId/brands/:brandId/prompts/:promptId': 'llmo/can_configure', 'PATCH /v2/orgs/:spaceCatId/categories/:categoryId': 'llmo/can_configure', 'PATCH /v2/orgs/:spaceCatId/topics/:topicId': 'llmo/can_configure', diff --git a/src/routes/index.js b/src/routes/index.js index a79249d6f5..8dd8d0abf7 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -213,6 +213,7 @@ export default function getRouteHandlers( 'DELETE /v2/orgs/:spaceCatId/topics/:topicId': brandsController.deleteTopicForOrg, 'POST /v2/orgs/:spaceCatId/brands': brandsController.createBrandForOrg, 'PATCH /v2/orgs/:spaceCatId/brands/:brandId': brandsController.updateBrandForOrg, + 'PATCH /v2/orgs/:spaceCatId/brands/:brandId/status': brandsController.transitionBrandStatusForOrg, 'DELETE /v2/orgs/:spaceCatId/brands/:brandId': brandsController.deleteBrandForOrg, 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/prompts': serenityController.listPrompts, 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/prompts': serenityController.createPrompts, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 4b61ebda5e..815c4f18f6 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -280,6 +280,7 @@ const routeRequiredCapabilities = { 'DELETE /v2/orgs/:spaceCatId/topics/:topicId': 'organization:write', 'POST /v2/orgs/:spaceCatId/brands': 'organization:write', 'PATCH /v2/orgs/:spaceCatId/brands/:brandId': 'organization:write', + 'PATCH /v2/orgs/:spaceCatId/brands/:brandId/status': 'organization:write', 'DELETE /v2/orgs/:spaceCatId/brands/:brandId': 'organization:write', 'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts': 'organization:read', 'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/stats': 'organization:read', diff --git a/src/support/brands-storage.js b/src/support/brands-storage.js index a17498268e..ea6bd2fbe6 100644 --- a/src/support/brands-storage.js +++ b/src/support/brands-storage.js @@ -1129,6 +1129,64 @@ export async function deleteBrand(organizationId, brandId, postgrestClient, upda return !!data; } +/** + * Explicitly sets a brand's lifecycle status (the intentful status-transition path, + * e.g. approve -> active, move-to-pending -> pending). + * + * This is deliberately kept separate from updateBrand and minimal (status + updated_by + * only, no child-table sync). The generic updateBrand path carries the active->pending + * demotion guard (LLMO-5587); legitimate, intended transitions route through here so they + * are not blocked by that guard. + * + * @param {object} params + * @param {string} params.organizationId - SpaceCat organization UUID + * @param {string} params.brandId - Brand UUID + * @param {string} params.status - Target status ('active' | 'pending') + * @param {object} params.postgrestClient - PostgREST client + * @param {string} [params.updatedBy] - User performing the operation + * @returns {Promise} Updated brand in V2 shape, or null if not found + */ +export async function setBrandStatus({ + organizationId, + brandId, + status, + postgrestClient, + updatedBy = 'system', +}) { + if (!postgrestClient?.from) { + throw new Error('PostgREST client is required'); + } + + const { data, error } = await postgrestClient + .from('brands') + .update({ status, updated_by: updatedBy }) + .eq('organization_id', organizationId) + .eq('id', brandId) + // Do not resurrect a soft-deleted brand via a status transition — a deleted + // brand matches no row here, so the caller gets a 404 (use a dedicated + // undelete flow if reactivation is ever needed). + .neq('status', 'deleted') + .select('id') + .maybeSingle(); + + if (error) { + // Lifted from Igor Grubic's PR #2504 (LLMO-5183): the data layer enforces + // chk_active_brand_has_site_id (an active brand must have a base site_id). Map the + // CheckViolation to a typed 400 rather than surfacing a generic 500. + if (error.code === '23514' && error.message?.includes('chk_active_brand_has_site_id')) { + const err = new Error('Cannot activate a brand without a base site URL'); + err.status = 400; + throw err; + } + throw new Error(`Failed to set brand status: ${error.message}`); + } + + if (!data) { + return null; + } + return getBrandById(organizationId, brandId, postgrestClient); +} + /** * Lists all regions (available markets) from the regions reference table. * diff --git a/test/controllers/brands.test.js b/test/controllers/brands.test.js index ca579543c3..5aa110f328 100644 --- a/test/controllers/brands.test.js +++ b/test/controllers/brands.test.js @@ -6415,6 +6415,256 @@ describe('Brands Controller', () => { expect(response.status).to.equal(500); }); }); + + describe('transitionBrandStatusForOrg', () => { + const BRAND_UUID = 'a1111111-1111-4111-b111-111111111111'; + + beforeEach(() => { + mockDataAccess.services.postgrestClient = { + from: sandbox.stub().callsFake(() => ({ + select: sandbox.stub().returnsThis(), + eq: sandbox.stub().returnsThis(), + neq: sandbox.stub().returnsThis(), + in: sandbox.stub().returnsThis(), + order: sandbox.stub().returnsThis(), + update: sandbox.stub().returnsThis(), + ilike: sandbox.stub().returnsThis(), + maybeSingle: sandbox.stub().resolves({ + data: { + id: BRAND_UUID, + name: 'Express', + status: 'pending', + origin: 'human', + updated_at: '2026-01-02T00:00:00Z', + updated_by: 'user@test.com', + brand_aliases: [], + brand_social_accounts: [], + brand_earned_sources: [], + competitors: [], + brand_sites: [], + }, + error: null, + }), + })), + }; + brandsController = BrandsController(context, loggerStub, mockEnv); + }); + + it('returns 200 and transitions status via the explicit path (LLMO-5587)', async () => { + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID }, + data: { status: 'pending' }, + dataAccess: mockDataAccess, + attributes: { authInfo: { profile: { email: 'user@test.com' } } }, + }); + expect(response.status).to.equal(200); + }); + + it('returns 400 when status is not active or pending', async () => { + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID }, + data: { status: 'deleted' }, + dataAccess: mockDataAccess, + }); + expect(response.status).to.equal(400); + }); + + it('returns 400 when status is missing', async () => { + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID }, + data: {}, + dataAccess: mockDataAccess, + }); + expect(response.status).to.equal(400); + }); + + it('returns 400 when organization ID is missing', async () => { + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { brandId: BRAND_UUID }, + data: { status: 'pending' }, + dataAccess: mockDataAccess, + }); + expect(response.status).to.equal(400); + }); + + it('returns 400 when spaceCatId is not a valid UUID', async () => { + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { spaceCatId: 'not-a-uuid', brandId: BRAND_UUID }, + data: { status: 'pending' }, + dataAccess: mockDataAccess, + }); + expect(response.status).to.equal(400); + }); + + it('returns 400 when brandId is missing', async () => { + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID }, + data: { status: 'pending' }, + dataAccess: mockDataAccess, + }); + expect(response.status).to.equal(400); + }); + + it('returns 404 when organization is not found', async () => { + mockDataAccess.Organization.findById.resolves(null); + brandsController = BrandsController(context, loggerStub, mockEnv); + + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID }, + data: { status: 'pending' }, + dataAccess: mockDataAccess, + }); + expect(response.status).to.equal(404); + }); + + it('returns 503 when postgrestClient is unavailable', async () => { + mockDataAccess.services.postgrestClient = null; + brandsController = BrandsController(context, loggerStub, mockEnv); + + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID }, + data: { status: 'pending' }, + dataAccess: mockDataAccess, + }); + expect(response.status).to.equal(503); + }); + + it('returns 404 when brand not found during resolve', async () => { + mockDataAccess.services.postgrestClient = { + from: sandbox.stub().callsFake(() => ({ + select: sandbox.stub().returnsThis(), + eq: sandbox.stub().returnsThis(), + neq: sandbox.stub().returnsThis(), + order: sandbox.stub().returnsThis(), + update: sandbox.stub().returnsThis(), + ilike: sandbox.stub().returnsThis(), + maybeSingle: sandbox.stub().resolves({ data: null, error: null }), + })), + }; + brandsController = BrandsController(context, loggerStub, mockEnv); + + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID }, + data: { status: 'pending' }, + dataAccess: mockDataAccess, + }); + expect(response.status).to.equal(404); + }); + + it('returns 404 when the brand is soft-deleted (no resurrection via status transition)', async () => { + const maybeSingleStub = sandbox.stub(); + // resolveBrandUuid succeeds... + maybeSingleStub.onFirstCall().resolves({ data: { id: BRAND_UUID }, error: null }); + // ...but the status update is filtered out by .neq('status','deleted') → no row. + maybeSingleStub.onSecondCall().resolves({ data: null, error: null }); + + mockDataAccess.services.postgrestClient = { + from: sandbox.stub().callsFake(() => ({ + select: sandbox.stub().returnsThis(), + eq: sandbox.stub().returnsThis(), + neq: sandbox.stub().returnsThis(), + order: sandbox.stub().returnsThis(), + update: sandbox.stub().returnsThis(), + ilike: sandbox.stub().returnsThis(), + maybeSingle: maybeSingleStub, + })), + }; + brandsController = BrandsController(context, loggerStub, mockEnv); + + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID }, + data: { status: 'active' }, + dataAccess: mockDataAccess, + attributes: { authInfo: { profile: { email: 'user@test.com' } } }, + }); + expect(response.status).to.equal(404); + }); + + it('returns 403 when user lacks access', async () => { + const authContextUser = { + attributes: { + authInfo: new AuthInfo() + .withType('jwt') + .withScopes([{ name: 'user' }]) + .withProfile({ is_admin: false }) + .withAuthenticated(true), + }, + }; + const unauthorizedController = BrandsController({ + dataAccess: mockDataAccess, + pathInfo: { headers: { 'x-product': 'llmo' } }, + ...authContextUser, + }, loggerStub, mockEnv); + + const response = await unauthorizedController.transitionBrandStatusForOrg({ + params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID }, + data: { status: 'pending' }, + dataAccess: mockDataAccess, + }); + expect(response.status).to.equal(403); + }); + + it('returns 400 when activating a brand without a base site (chk_active_brand_has_site_id, lifted from #2504)', async () => { + const maybeSingleStub = sandbox.stub(); + // resolveBrandUuid resolves the UUID... + maybeSingleStub.onFirstCall().resolves({ data: { id: BRAND_UUID }, error: null }); + // ...then setBrandStatus hits the DB constraint on the update. + maybeSingleStub.onSecondCall().resolves({ + data: null, + error: { + code: '23514', + message: 'new row violates check constraint "chk_active_brand_has_site_id"', + }, + }); + + mockDataAccess.services.postgrestClient = { + from: sandbox.stub().callsFake(() => ({ + select: sandbox.stub().returnsThis(), + eq: sandbox.stub().returnsThis(), + neq: sandbox.stub().returnsThis(), + order: sandbox.stub().returnsThis(), + update: sandbox.stub().returnsThis(), + ilike: sandbox.stub().returnsThis(), + maybeSingle: maybeSingleStub, + })), + }; + brandsController = BrandsController(context, loggerStub, mockEnv); + + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID }, + data: { status: 'active' }, + dataAccess: mockDataAccess, + attributes: { authInfo: { profile: { email: 'user@test.com' } } }, + }); + expect(response.status).to.equal(400); + }); + + it('returns 500 when storage throws', async () => { + mockDataAccess.services.postgrestClient = { + from: sandbox.stub().throws(new Error('DB connection lost')), + }; + brandsController = BrandsController(context, loggerStub, mockEnv); + + const response = await brandsController.transitionBrandStatusForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID, brandId: BRAND_UUID }, + data: { status: 'pending' }, + dataAccess: mockDataAccess, + }); + expect(response.status).to.equal(500); + }); + }); }); describe('Brands Controller — region removal consistency guard (LLMO-5645)', () => { diff --git a/test/routes/index.test.js b/test/routes/index.test.js index df4207a2bd..c60454bb38 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -821,6 +821,7 @@ describe('getRouteHandlers', () => { 'DELETE /v2/orgs/:spaceCatId/topics/:topicId', 'POST /v2/orgs/:spaceCatId/brands', 'PATCH /v2/orgs/:spaceCatId/brands/:brandId', + 'PATCH /v2/orgs/:spaceCatId/brands/:brandId/status', 'DELETE /v2/orgs/:spaceCatId/brands/:brandId', 'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts', 'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/stats', diff --git a/test/support/brands-storage.test.js b/test/support/brands-storage.test.js index eb187fe269..e82158f28f 100644 --- a/test/support/brands-storage.test.js +++ b/test/support/brands-storage.test.js @@ -26,6 +26,7 @@ import { upsertBrand, updateBrand, deleteBrand, + setBrandStatus, listRegions, } from '../../src/support/brands-storage.js'; @@ -2805,4 +2806,89 @@ describe('brands-storage', () => { await expect(deleteBrand(ORG_ID, BRAND_ID, postgrestClient)).to.be.rejectedWith('Failed to delete brand: delete failed'); }); }); + + describe('setBrandStatus', () => { + it('throws when postgrestClient is missing', async () => { + await expect(setBrandStatus({ + organizationId: ORG_ID, brandId: BRAND_ID, status: 'pending', postgrestClient: null, + })).to.be.rejectedWith('PostgREST client is required'); + }); + + it('updates status and returns the mapped brand (LLMO-5587 intentful path)', async () => { + const postgrestClient = createTableMockClient({ + brands: [ + // 1st call: status update + { data: { id: BRAND_ID }, error: null }, + // 2nd call: getBrandById re-fetch + { data: makeBrandRow({ status: 'pending' }), error: null }, + ], + }); + + const result = await setBrandStatus({ + organizationId: ORG_ID, + brandId: BRAND_ID, + status: 'pending', + postgrestClient, + updatedBy: 'user@test.com', + }); + + expect(result).to.not.be.null; + expect(result.status).to.equal('pending'); + }); + + it('returns null when the brand is not found', async () => { + const postgrestClient = createTableMockClient({ + brands: { data: null, error: null }, + }); + + const result = await setBrandStatus({ + organizationId: ORG_ID, brandId: BRAND_ID, status: 'active', postgrestClient, + }); + + expect(result).to.be.null; + }); + + it('maps chk_active_brand_has_site_id violation to a 400 (lifted from #2504)', async () => { + const postgrestClient = createTableMockClient({ + brands: [{ + data: null, + error: { + code: '23514', + message: 'new row violates check constraint "chk_active_brand_has_site_id"', + }, + }], + }); + + const err = await setBrandStatus({ + organizationId: ORG_ID, brandId: BRAND_ID, status: 'active', postgrestClient, + }).catch((e) => e); + + expect(err.message).to.equal('Cannot activate a brand without a base site URL'); + expect(err.status).to.equal(400); + }); + + it('throws a generic error on other database failures', async () => { + const postgrestClient = createTableMockClient({ + brands: [{ data: null, error: { message: 'boom' } }], + }); + + await expect(setBrandStatus({ + organizationId: ORG_ID, brandId: BRAND_ID, status: 'pending', postgrestClient, + })).to.be.rejectedWith('Failed to set brand status: boom'); + }); + + it('does not resurrect a soft-deleted brand (the .neq filter matches no row → null)', async () => { + // A deleted brand is excluded by .neq('status','deleted'), so the update + // affects no row and the function returns null (controller → 404). + const postgrestClient = createTableMockClient({ + brands: { data: null, error: null }, + }); + + const result = await setBrandStatus({ + organizationId: ORG_ID, brandId: BRAND_ID, status: 'active', postgrestClient, + }); + + expect(result).to.be.null; + }); + }); }); diff --git a/test/support/slack/preflight/preflight-config.test.js b/test/support/slack/preflight/preflight-config.test.js index 4d76efbcf9..723148f01b 100644 --- a/test/support/slack/preflight/preflight-config.test.js +++ b/test/support/slack/preflight/preflight-config.test.js @@ -124,6 +124,17 @@ describe('preflight-config helpers', () => { expect(getPreflightMissingConfigLabels(site)).to.deep.equal(['AMS URL']); }); + + it('treats null getDeliveryConfig() and getHlxConfig() as empty objects (lines 71-72 fallback)', () => { + const site = { + getAuthoringType: () => 'cs', + getDeliveryConfig: () => null, + getHlxConfig: () => null, + }; + + // null delivery config → || {} → no programId/environmentId → flags AEM CS Preview URL + expect(getPreflightMissingConfigLabels(site)).to.deep.equal(['AEM CS Preview URL']); + }); }); describe('promptPreflightConfig', () => { @@ -348,6 +359,33 @@ describe('preflight-config helpers', () => { expect(result.needsContentSourcePath).to.be.true; }); + it('treats null getDeliveryConfig() as empty object inside isPreflightSiteConfigReady (line 157 fallback)', async () => { + // getDeliveryConfig is called twice: + // 1st call (getPreflightMissingConfigLabels): return a valid config (no missing labels) + // 2nd call (line 157 in isPreflightSiteConfigReady): return null so the || {} branch runs + const getDeliveryConfigStub = sandbox.stub(); + getDeliveryConfigStub.onFirstCall().returns({ programId: '12345', environmentId: '67890' }); + getDeliveryConfigStub.onSecondCall().returns(null); + + const site = { + getId: () => 'site1', + getOrganizationId: () => 'org1', + getAuthoringType: () => 'cs', + getDeliveryConfig: getDeliveryConfigStub, + getHlxConfig: () => ({}), + }; + const context = { + dataAccess: { + Site: { allByExternalOwnerIdAndExternalSiteId: sandbox.stub().resolves([site]) }, + }, + }; + + const result = await isPreflightSiteConfigReady(site, context); + // null → {} → no programId/environmentId → isContentSourcePathRequired returns false → ready + expect(result.ready).to.be.true; + expect(result.needsContentSourcePath).to.be.false; + }); + it('returns ready when CS site has all required config including content source path', async () => { const site = { getId: () => 'site1',