From 8e40d4ee4c4683cb2e996228ac42b29e5f5e8434 Mon Sep 17 00:00:00 2001 From: Igor Grubic Date: Tue, 21 Jul 2026 16:58:04 +0200 Subject: [PATCH 1/3] feat(serenity): org-flag brand-create mode, sub-workspace-only pending activation, site_id anchoring (LLMO-6405) Backend for the brand & market management rework (serenity-docs #68). Market-scoped inputs move out of brand creation into market creation. - createBrandForOrg: Semrush mode is decided by the org serenity flag (isSerenityActiveForOrg), not the request body. A non-serenity org sending a market or generatePrompts:true is rejected (403); a bare generatePrompts:false stays a flat create. A serenity create WITHOUT a market provisions a bare sub-workspace (no project) via the new provisionBrandSubworkspaceBare; a supplied market keeps the existing project path. - upsertBrand: write brands.site_id from baseSiteId even on the Semrush-anchored path (a Semrush brand is anchored by BOTH its sub-workspace and its primary site). The DB CHECK tightening + NULL backfill remain a follow-up migration. - /serenity/activate: a pending brand activates sub-workspace-only (ensure sub-workspace + flip active + clear stash), never auto-creating a market. The body-driven market path runs only for an already-active brand (reactivation / onboarding API) and never downgrades it on a partial failure (207). Tests: serenity activate block recast to the new model + new pending sub-workspace-only cases; upsertBrand site_id tests updated; provisionBrandSubworkspaceBare covered. npm test + coverage green; type-check + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/brands.js | 71 +++--- src/controllers/serenity.js | 157 ++++++------ src/support/brands-storage.js | 40 ++- src/support/serenity/brand-provisioning.js | 95 ++++++- test/controllers/brands.test.js | 131 ++++++++-- test/controllers/serenity.test.js | 234 +++++++++++------- test/support/brands-storage.test.js | 18 +- .../serenity/brand-provisioning.test.js | 164 ++++++++++++ 8 files changed, 663 insertions(+), 247 deletions(-) diff --git a/src/controllers/brands.js b/src/controllers/brands.js index 8b32eb126b..b9dd0c77df 100644 --- a/src/controllers/brands.js +++ b/src/controllers/brands.js @@ -62,7 +62,7 @@ import { } from '../support/brands-storage.js'; import { listViewableResourceIds } from '../support/state-access-mapping-utils.js'; import { isFacsRebacResource } from '../routes/facs-capabilities.js'; -import { provisionBrandSubworkspace, releaseProvisionedWorkspace } from '../support/serenity/brand-provisioning.js'; +import { provisionBrandSubworkspace, provisionBrandSubworkspaceBare, releaseProvisionedWorkspace } from '../support/serenity/brand-provisioning.js'; import { ensureMarketSite } from '../support/serenity/site-linkage.js'; import { upsertMappingRow, linkSiteToLiveRows } from '../support/serenity/mapping-rows.js'; import { createSerenityTransport } from '../support/serenity/rest-transport.js'; @@ -1516,39 +1516,28 @@ function BrandsController(ctx, log, env) { const isPendingBrand = brandData.status === 'pending'; const { semrushMarket } = brandData; const hasSemrushMarket = isNonEmptyObject(semrushMarket); - // generatePrompts (default false) gates topic/prompt generation ONLY. The - // wizard sends it as an explicit boolean for every Semrush-mode create, so - // its presence ALSO signals Semrush mode even when no market was picked — - // a bare "save and continue later" draft (location/language optional). + // generatePrompts (default false) gates topic/prompt generation for a supplied + // market ONLY; it no longer signals Semrush mode (see below). const generatePrompts = brandData.generatePrompts === true; - // The wizard always sends `generatePrompts` as an explicit boolean for a - // Semrush-mode create; a flat (non-Semrush) create omits it entirely. So - // the mere PRESENCE of the flag (true OR false) is itself a Semrush-mode - // signal — but see below: only trusted for a draft. - const hasGeneratePromptsFlag = typeof brandData.generatePrompts === 'boolean'; - // A draft (pending) brand may legitimately be a "sub-workspace-only Semrush - // brand, save and continue later": no market, generatePrompts:false. The - // flag's presence is what marks it as Semrush mode (see - // normalizePendingSemrushProvisioning, which stashes a bare no-prompt draft). - const isSubworkspaceOnlyDraft = isPendingBrand && hasGeneratePromptsFlag; - // Semrush-mode detection. A LIVE create must carry a POSITIVE signal — a - // picked market, or generatePrompts:true (which itself requires a market, - // enforced below). We deliberately do NOT treat the mere presence of the - // flag as the signal on the live path: a flat caller that defensively sends - // `generatePrompts:false` must not be pulled into Semrush provisioning (it - // would 400 for a missing primary URL, or worse, provision a sub-workspace - // for a brand never meant to have one). Presence is trusted ONLY for a draft. - const isSemrushMode = hasSemrushMarket || generatePrompts || isSubworkspaceOnlyDraft; - // Serenity rollout gate. Provisioning a Semrush sub-workspace / project for a - // brand is a serenity-active operation. While serenity is inactive for the - // org, refuse a Semrush-mode create rather than provision upstream: the - // flag-gated UI won't request it, and an org still on the normal backend data - // must not get a sub-workspace before its rollout flag is flipped on. The - // helper is only consulted on the Semrush-mode path, so a plain (flat) create - // is unaffected. (Effective gate is flag AND workspace, same as /serenity/*.) - if (isSemrushMode && !await isSerenityActiveForOrg(context, spaceCatId, log)) { + // Semrush-mode detection (LLMO-6405). Mode is the ORG's serenity rollout flag, + // NOT the create body: market-scoped inputs (market, AI models, generate-prompts) + // have moved out of brand creation into market creation, so in a serenity-active + // org EVERY brand create is a Semrush create (§4.1 product invariant) — it + // provisions the brand's sub-workspace, and its markets are added afterwards from + // the Markets tab. A legacy body market/generatePrompts flag is still HONORED when + // present (a supplied market provisions its project as before) but no longer + // DECIDES the mode. A flat (brandalf) org is never serenity-active, so its creates + // stay flat and untouched. (Effective gate is still flag AND workspace, same as + // /serenity/* — resolveWorkspaceId inside provisioning 400s a workspace-less org.) + const serenityActive = await isSerenityActiveForOrg(context, spaceCatId, log); + // Safety: a non-serenity org must never be pulled into Semrush provisioning by a + // stray body field — reject an ACTUAL provisioning request (a supplied market, or + // generatePrompts:true). A bare generatePrompts:false from a flat caller is + // harmless and stays a flat create. + if (!serenityActive && (hasSemrushMarket || generatePrompts)) { return forbidden('Serenity is not active for this organization'); } + const isSemrushMode = serenityActive; if (isSemrushMode) { let market; let languageCode; @@ -1602,7 +1591,7 @@ function BrandsController(ctx, log, env) { markets, generatePrompts, }; - } else { + } else if (hasSemrushMarket) { const brandDomain = brandDomainFromPayload(brandData); if (!brandDomain || !hasText(brandDomain)) { return badRequest('A primary URL is required to provision a Semrush brand'); @@ -1664,6 +1653,18 @@ function BrandsController(ctx, log, env) { geoTargetId: provisioned.geoTargetId, languageCode: provisioned.languageCode, }; + } else { + // B (LLMO-6405): sub-workspace-only active create — no market supplied, so + // no project is provisioned. Markets are added afterwards from the Markets + // tab. The brand is anchored by its primary site (baseSiteId, persisted by + // upsertBrand below) AND by its Semrush sub-workspace. + provisionedBrandId = randomUUID(); + const bare = await provisionBrandSubworkspaceBare(context, { + spaceCatId, + brandId: provisionedBrandId, + brandName: brandData.name, + }, log); + provisionedWorkspaceId = bare.semrushSubWorkspaceId; } } @@ -1717,7 +1718,11 @@ function BrandsController(ctx, log, env) { // whose catch releases the just-provisioned workspace; a throw here would // tear down a live brand's workspace. ensureMarketSite is best-effort by // contract (its own catch-all swallows + logs), so this holds. - if (provisionedWorkspaceId && hasText(provisionedWorkspaceId)) { + // Only when an initial MARKET was provisioned (project path) — a + // sub-workspace-only create (B) has no market domain to mirror, so it skips + // this. The brand's own primary site is set from baseSiteId by upsertBrand. + if (provisionedWorkspaceId && hasText(provisionedWorkspaceId) + && provisionedBrandDomain && hasText(provisionedBrandDomain)) { const linkedSiteId = await ensureMarketSite(context, { organizationId: spaceCatId, brandId: provisionedBrandId ?? undefined, diff --git a/src/controllers/serenity.js b/src/controllers/serenity.js index 334ecd4ca7..eed2cf86be 100644 --- a/src/controllers/serenity.js +++ b/src/controllers/serenity.js @@ -1094,27 +1094,79 @@ function SerenityController(context, log, env) { ? body.brandDomain : hostnameFromUrlString(pendingSemrushProvisioning?.primaryUrl); + // ----- Pending-brand activation is ALWAYS sub-workspace-only (LLMO-6405) ----- + // Markets are Semrush projects added afterwards from the Markets tab, never + // auto-created at activation — so a pending brand activates to just its + // sub-workspace (the anchor, persisted by ensureSubworkspace) plus a status + // flip, IGNORING any stashed primary URL for project creation. The brand's + // primary site (brands.site_id) was set at create. Reactivation of an already + // ACTIVE brand (body-driven markets) is handled by the branches below. + if (wasPending) { + const pendingWorkspaceId = await ensureSubworkspace( + transport, + brand, + auth.parentWorkspaceId ?? '', + 1, + log, + {}, + brandPointerReloader(ctx, auth.brandUuid), + { + dynamicAllocation: dynamicAllocationEnabled(ctx), + }, + ); + let pendingActivateSucceeded = true; + if (typeof brand.setStatus === 'function') { + brand.setStatus('active'); + } + if (hadPendingSemrushProvisioning + && typeof brand.setPendingSemrushProvisioning === 'function') { + brand.setPendingSemrushProvisioning(null); + } + try { + await brand.save(); + } catch (saveError) { + pendingActivateSucceeded = false; + log.error('serenity activate: SERENITY_ACTIVATE_SAVE_DIVERGENCE — sub-workspace ensured upstream but failed to persist active status', { + brandId: auth.brandUuid, + semrushWorkspaceId: pendingWorkspaceId, + error: saveError?.message, + }); + } + log.info('serenity activate: completed (pending → active, sub-workspace only)', { + brandId: auth.brandUuid, + semrushWorkspaceId: pendingWorkspaceId, + fullySucceeded: pendingActivateSucceeded, + }); + if (pendingActivateSucceeded) { + return createResponse( + { brandId: auth.brandUuid, status: 'active', markets: [] }, + 200, + ); + } + // Sub-workspace ensured upstream but the active flip did not persist — the + // brand stays pending; a retry converges (the sub-workspace 409s idempotently). + return createResponse( + { + brandId: auth.brandUuid, + status: 'pending', + error: 'serenityActivationIncomplete', + message: 'Sub-workspace provisioned but the active status could not be persisted.', + markets: [], + }, + 502, + ); + } + // ----- Sub-workspace-only activation (no primary URL → no project) ----- - // A brand with no domain has nothing to provision a project against: just - // ensure its sub-workspace (which IS the active-brand anchor, persisted by - // ensureSubworkspace) and flip it active. This is the bare "save & continue - // later" draft; the user adds markets (projects) afterwards from the Markets - // tab. generatePrompts can't apply with no project, so reject the combo. + // Reactivation of an already-ACTIVE brand with no primary URL — a no-op flip. + // (A pending brand never reaches here; it returned above.) Ensure its + // sub-workspace and re-affirm active. if (!hasText(brandDomain)) { - // A URL/domain WAS supplied but did not resolve to a hostname → bad input, - // not a bare brand. Fail fast (a silent fallback would mask the typo and - // strand the user with a project-less brand they did not ask for). + // A URL/domain WAS supplied but did not resolve to a hostname → bad input. + // Fail fast (a silent fallback would mask the typo). if (suppliedUrlOrDomain) { throw new ErrorWithStatusCode('brandDomain is required to provision a Semrush market', 400); } - // An ACTIVE brand must always be anchored by a primary site (brands.site_id); - // only a pending draft may be site-less. So a pending brand with no primary - // URL cannot be activated — reject and leave it pending until the user adds - // one. (An already-active brand re-supplying no URL is a no-op reactivation, - // handled by the flip-and-save below.) - if (wasPending) { - throw new ErrorWithStatusCode('A primary URL is required to activate a brand', 400); - } if (generatePrompts) { throw new ErrorWithStatusCode('A primary URL is required to generate prompts', 400); } @@ -1134,13 +1186,11 @@ function SerenityController(context, log, env) { if (typeof brand.setStatus === 'function') { brand.setStatus('active'); } - if (hadPendingSemrushProvisioning - && typeof brand.setPendingSemrushProvisioning === 'function') { - brand.setPendingSemrushProvisioning(null); - } try { await brand.save(); } catch (saveError) { + // An already-active brand's re-flip failed transiently; it stays active + // (the flip was a no-op anyway). Log and return 207. bareSucceeded = false; log.error('serenity activate: SERENITY_ACTIVATE_SAVE_DIVERGENCE — sub-workspace ensured upstream but failed to persist active status', { brandId: auth.brandUuid, @@ -1148,35 +1198,14 @@ function SerenityController(context, log, env) { error: saveError?.message, }); } - log.info('serenity activate: completed (sub-workspace only)', { + log.info('serenity activate: completed (sub-workspace only, active reactivation)', { brandId: auth.brandUuid, semrushWorkspaceId: bareWorkspaceId, fullySucceeded: bareSucceeded, }); - if (bareSucceeded) { - return createResponse( - { brandId: auth.brandUuid, status: 'active', markets: [] }, - 200, - ); - } - // Save failed: a pending draft stays pending (retryable, idempotent — the - // sub-workspace 409s on retry); an already-active brand is left active - // (the flip was a no-op anyway). - if (wasPending) { - return createResponse( - { - brandId: auth.brandUuid, - status: 'pending', - error: 'serenityActivationIncomplete', - message: 'Sub-workspace provisioned but the active status could not be persisted.', - markets: [], - }, - 502, - ); - } return createResponse( { brandId: auth.brandUuid, status: 'active', markets: [] }, - 207, + bareSucceeded ? 200 : 207, ); } @@ -1434,43 +1463,11 @@ function SerenityController(context, log, env) { ); } - // Not fully succeeded. A pending-draft activation that did not complete - // every step STAYS pending and returns an ERROR (HTTP 502: the upstream - // provisioning chain is incomplete) naming the failed step, with the - // per-market results so the caller can show specifics and retry. - if (wasPending) { - if (allMarketsLive && !siteLinked) { - // Every market is LIVE upstream, but the brand stays 'pending' because - // the brand_sites mirror did not link (a transient write error, or the - // type='serenity' migration not yet deployed — see - // SERENITY_MARKET_LINK_REJECTED in site-linkage.js). The brand is dark - // on our side despite live markets until a retry re-links. Emit a - // DISTINCT, greppable token so this strand is alertable rather than - // hidden in a generic 502; it self-heals on idempotent re-activate. - log.error('serenity activate: SERENITY_ACTIVATE_LINK_INCOMPLETE — all markets live upstream but brand_sites mirror failed; brand stays pending', { - brandId: auth.brandUuid, - semrushWorkspaceId: workspaceId, - marketsLive: marketsLiveCount, - }); - } - const failureReason = !allMarketsLive - ? 'One or more markets failed to provision.' - : 'Markets were provisioned but could not be linked as sites (brand_sites).'; - return createResponse( - { - brandId: auth.brandUuid, - status: 'pending', - error: 'serenityActivationIncomplete', - message: failureReason, - markets: results, - }, - 502, - ); - } - - // An already-active brand re-supplying markets (reactivation) is never - // downgraded: a single failed market is reported as 207 Multi-Status while - // the brand remains active. + // Not fully succeeded. The body-driven market path (reactivation / onboarding + // API) runs ONLY for a brand that is already ACTIVE — a pending brand activates + // sub-workspace-only above (LLMO-6405) and never reaches here. An already-active + // brand is never downgraded on a partial failure: a failed market is reported as + // 207 Multi-Status while the brand stays active. return createResponse( { brandId: auth.brandUuid, status: 'active', markets: results }, 207, diff --git a/src/support/brands-storage.js b/src/support/brands-storage.js index e2f432ea96..fd4b27bc1a 100644 --- a/src/support/brands-storage.js +++ b/src/support/brands-storage.js @@ -972,13 +972,6 @@ export async function upsertBrand({ row.pending_semrush_provisioning = pendingSemrushProvisioning; } - // A Semrush-anchored create (serenity-first, semrushSubWorkspaceId set) is - // NEVER anchored by a SpaceCat site: its primary URL is the Semrush project - // domain, which may coincidentally match an onboarded site. Setting site_id - // from that match would collide with the site's existing primary brand (409 - // brands_base_site_unique) — so ignore baseSiteId entirely on this path. - const anchoredBySemrush = hasText(semrushSubWorkspaceId); - // baseSiteId is immutable once persisted (mirrors updateBrand). Only set it // when the brand has no site_id yet — re-onboarding/re-upserting an existing // brand by name must NOT re-point its primary site (LLMO-5556: this silently @@ -988,20 +981,25 @@ export async function upsertBrand({ // In both cases we always write an explicit site_id — or null to clear the // deleted brand's stale anchor so it cannot survive the ON CONFLICT UPDATE // and collide with whichever brand now owns that site. - if (!anchoredBySemrush) { - if (existing === null) { - row.site_id = hasText(brand.baseSiteId) ? brand.baseSiteId : null; - } else if (hasText(brand.baseSiteId) && !hasText(existing.site_id)) { - row.site_id = brand.baseSiteId; - } else if ( - hasText(brand.baseSiteId) - && hasText(existing.site_id) - && existing.site_id !== brand.baseSiteId - ) { - log.warn(`upsertBrand: ignoring baseSiteId change for brand "${brand.name}" ` - + `(org ${organizationId}) — primary site is immutable ` - + `(existing=${existing.site_id}, attempted=${brand.baseSiteId})`); - } + // LLMO-6405: a Semrush-anchored (serenity-first) create now ALSO carries a + // primary site — the UI's primary-URL step selects an onboarded Site and sends + // its baseSiteId, so brands.site_id is populated on every path (a Semrush brand + // is anchored by BOTH its sub-workspace AND its primary site). The previous skip + // (which left Semrush brands' site_id NULL) is removed; a genuine collision with + // another brand's primary site still surfaces as the brands_base_site_unique 409 + // handled below. + if (existing === null) { + row.site_id = hasText(brand.baseSiteId) ? brand.baseSiteId : null; + } else if (hasText(brand.baseSiteId) && !hasText(existing.site_id)) { + row.site_id = brand.baseSiteId; + } else if ( + hasText(brand.baseSiteId) + && hasText(existing.site_id) + && existing.site_id !== brand.baseSiteId + ) { + log.warn(`upsertBrand: ignoring baseSiteId change for brand "${brand.name}" ` + + `(org ${organizationId}) — primary site is immutable ` + + `(existing=${existing.site_id}, attempted=${brand.baseSiteId})`); } const { data: upserted, error } = await postgrestClient diff --git a/src/support/serenity/brand-provisioning.js b/src/support/serenity/brand-provisioning.js index dc3bc9e861..44dd4f058a 100644 --- a/src/support/serenity/brand-provisioning.js +++ b/src/support/serenity/brand-provisioning.js @@ -18,7 +18,7 @@ import { ErrorWithStatusCode, resolveSemrushImsToken } from '../utils.js'; import { createSerenityTransport } from './rest-transport.js'; import { isSemrushTransportError } from './errors.js'; import { resolveWorkspaceId } from './workspace-resolver.js'; -import { deleteAllProjects, releaseFullAllocation } from './workspace-lifecycle.js'; +import { deleteAllProjects, releaseFullAllocation, ensureSubworkspace } from './workspace-lifecycle.js'; import { handleCreateMarketSubworkspace } from './handlers/markets-subworkspace.js'; // Re-exported for callers/tests that drive brand provisioning. The tag @@ -258,6 +258,99 @@ export async function provisionBrandSubworkspace(context, { }; } +/** + * Sub-workspace-only provisioning for a brand created in Semrush mode WITHOUT an + * initial market (LLMO-6405). Market-scoped inputs moved out of brand creation, so + * a serenity-active brand is created with just its Semrush sub-workspace (the + * active-brand anchor) and NO project; its markets are added afterwards from the + * Markets tab. Mirrors {@link provisionBrandSubworkspace}'s serenity-first contract + * (sub-workspace created BEFORE the brand row via a lightweight stub whose `save` is + * a no-op; the caller persists the returned id onto the new row). On failure the + * just-created sub-workspace's allocation is released back to the parent pool + * (the workspace itself is never deleted — production never deletes a sub-workspace). + * + * @param {object} context - request context (env, dataAccess, pathInfo headers). + * @param {object} params + * @param {string} params.spaceCatId - SpaceCat organization UUID. + * @param {string} params.brandId - pre-generated brand UUID (sub-workspace title key). + * @param {string} params.brandName - brand display name (sub-workspace title). + * @param {object} [log] + * @returns {Promise<{ semrushSubWorkspaceId: string }>} the new sub-workspace id. + * @throws {ErrorWithStatusCode} on workspace create failure (the caller then skips + * the brand write). + */ +export async function provisionBrandSubworkspaceBare(context, { + spaceCatId, brandId, brandName, +}, log = console) { + if (!hasText(brandName)) { + throw new ErrorWithStatusCode('brandName is required for Semrush provisioning', 400); + } + if (!hasText(brandId)) { + throw new ErrorWithStatusCode('brandId is required for Semrush provisioning', 400); + } + + const parentWorkspaceId = await resolveWorkspaceId(context, spaceCatId); + if (!parentWorkspaceId || !hasText(parentWorkspaceId)) { + throw new ErrorWithStatusCode('Organization has no Semrush workspace configured', 400); + } + + const imsToken = await resolveSemrushImsToken(context, log, 'brand-provisioning'); + const transport = createSerenityTransport({ env: context.env, imsToken }); + + /** @type {string|null} */ + let capturedWorkspaceId = null; + const brandStub = { + getId: () => brandId, + getName: () => brandName, + getSemrushSubWorkspaceId: () => undefined, + setSemrushSubWorkspaceId: (id) => { capturedWorkspaceId = id; }, + save: async () => {}, + }; + + try { + // marketCount = 1: the bare sub-workspace is carved for a single future project + // (the first market the user adds). ensureSubworkspace returns the new id. + const subWorkspaceId = await ensureSubworkspace( + transport, + brandStub, + parentWorkspaceId, + 1, + log, + {}, + null, + {}, + ); + const resolved = hasText(subWorkspaceId) ? subWorkspaceId : capturedWorkspaceId; + if (!resolved || !hasText(resolved)) { + throw new ErrorWithStatusCode('Semrush provisioning returned no sub-workspace id', 502); + } + return { semrushSubWorkspaceId: resolved }; + } catch (e) { + // Release the just-created (empty) sub-workspace's allocation on failure — the + // brand row is never written, so nothing references it. Best-effort; never masks + // the original error. deleteAllProjects tolerates an empty workspace. + // Narrow via a const (hasText is not a type guard, and a closure-mutated `let` + // is not narrowed by control flow — see this dir's CLAUDE.md). + const wsId = capturedWorkspaceId; + if (wsId && hasText(wsId)) { + try { + await deleteAllProjects(transport, wsId); + await releaseFullAllocation(transport, wsId, parentWorkspaceId, log); + log?.info?.( + 'serenity: emptied sub-workspace after failed bare brand provisioning — allocation lowered to floor', + { semrushWorkspaceId: wsId }, + ); + } catch (releaseErr) { + log?.error?.('serenity: failed to release sub-workspace allocation after failed bare provisioning', { + semrushWorkspaceId: wsId, + error: releaseErr?.message, + }); + } + } + throw e; + } +} + /** * Best-effort cleanup for a sub-workspace that was provisioned upstream but whose * brand row was never written (e.g. the post-provision DB upsert threw). Because diff --git a/test/controllers/brands.test.js b/test/controllers/brands.test.js index 1c4f42f0e4..871f982cb8 100644 --- a/test/controllers/brands.test.js +++ b/test/controllers/brands.test.js @@ -4781,14 +4781,20 @@ describe('Brands Controller', () => { }; async function buildController({ - provisionBrandSubworkspace, upsertBrand, ensureMarketSite, + provisionBrandSubworkspace, provisionBrandSubworkspaceBare, upsertBrand, ensureMarketSite, upsertMappingRow, linkSiteToLiveRows, // Serenity active by default (org-wide LLMO/serenity flag ON) so the // Semrush-mode create reaches provisioning. The inactive case overrides this. isSerenityActiveForOrg = sinon.stub().resolves(true), }) { const Mocked = await esmock('../../src/controllers/brands.js', { - '../../src/support/serenity/brand-provisioning.js': { provisionBrandSubworkspace }, + '../../src/support/serenity/brand-provisioning.js': { + provisionBrandSubworkspace, + // Bare (no-market) sub-workspace provisioner (LLMO-6405). Default-stubbed so a + // market-less serenity create does not reach the real upstream helper. + provisionBrandSubworkspaceBare: + provisionBrandSubworkspaceBare || sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-bare' }), + }, // Stub the site mirror by default so these tests stay isolated from the // Site/brand_sites side effect; a test that cares passes its own stub. '../../src/support/serenity/site-linkage.js': { @@ -5208,20 +5214,59 @@ describe('Brands Controller', () => { expect(provisionStub.called).to.equal(false); }); - it('does NOT provision when no semrushMarket is supplied (flat create)', async () => { + it('creates a bare sub-workspace (no project) when no market is supplied (serenity-active)', async () => { + // LLMO-6405: market-scoped inputs moved to market creation, so a serenity-active + // create with no market provisions just the sub-workspace (no project) — the + // market-based project provisioner is never called. const provisionStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-1' }); - const controller = await buildController({ provisionBrandSubworkspace: provisionStub }); + const bareStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-bare' }); + const upsertStub = sinon.stub().resolves({ id: 'forced-id', name: 'New Brand' }); + const controller = await buildController({ + provisionBrandSubworkspace: provisionStub, + provisionBrandSubworkspaceBare: bareStub, + upsertBrand: upsertStub, + }); const response = await controller.createBrandForOrg({ ...context, params: { spaceCatId: ORGANIZATION_ID }, - data: { name: 'New Brand' }, + data: { name: 'New Brand', baseSiteId: 'site-123' }, dataAccess: mockDataAccess, attributes: { authInfo: { getType: () => 'ims', profile: { email: 'user@test.com' } } }, }); expect(response.status).to.equal(201); expect(provisionStub.called).to.equal(false); + expect(bareStub.called).to.equal(true); + const upsertArgs = upsertStub.firstCall.args[0]; + // Anchored by BOTH the bare sub-workspace and the primary site. + expect(upsertArgs.semrushSubWorkspaceId).to.equal('ws-bare'); + expect(upsertArgs.brand.baseSiteId).to.equal('site-123'); + }); + + it('does a flat create (no Semrush provisioning) when the org is not serenity-active', async () => { + const provisionStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-1' }); + const bareStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-bare' }); + const upsertStub = sinon.stub().resolves({ id: 'flat-id', name: 'Flat Brand' }); + const controller = await buildController({ + provisionBrandSubworkspace: provisionStub, + provisionBrandSubworkspaceBare: bareStub, + upsertBrand: upsertStub, + isSerenityActiveForOrg: sinon.stub().resolves(false), + }); + + const response = await controller.createBrandForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID }, + data: { name: 'Flat Brand', baseSiteId: 'site-9' }, + dataAccess: mockDataAccess, + attributes: { authInfo: { getType: () => 'ims', profile: { email: 'user@test.com' } } }, + }); + + expect(response.status).to.equal(201); + expect(provisionStub.called).to.equal(false); + expect(bareStub.called).to.equal(false); + expect(upsertStub.firstCall.args[0].semrushSubWorkspaceId).to.equal(null); }); it('saves a pending draft WITHOUT a primary URL: no provisioning, market stashed (201)', async () => { @@ -5502,16 +5547,20 @@ describe('Brands Controller', () => { describe('competitor self-reference guard + Semrush-mode gating (create)', () => { async function buildCreateController({ - upsertBrand, provisionBrandSubworkspace, + upsertBrand, provisionBrandSubworkspace, provisionBrandSubworkspaceBare, isSerenityActiveForOrg = sinon.stub().resolves(true), }) { const mocks = { '../../src/support/brands-storage.js': { upsertBrand }, '../../src/support/serenity/serenity-active.js': { isSerenityActiveForOrg }, + // Bare (no-market) sub-workspace provisioner default-stubbed (LLMO-6405) so a + // market-less serenity create does not reach the real upstream helper. + '../../src/support/serenity/brand-provisioning.js': { + provisionBrandSubworkspaceBare: + provisionBrandSubworkspaceBare || sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-bare' }), + ...(provisionBrandSubworkspace ? { provisionBrandSubworkspace } : {}), + }, }; - if (provisionBrandSubworkspace) { - mocks['../../src/support/serenity/brand-provisioning.js'] = { provisionBrandSubworkspace }; - } const Mocked = await esmock('../../src/controllers/brands.js', mocks); return Mocked.default(context, loggerStub, mockEnv); } @@ -5574,15 +5623,50 @@ describe('Brands Controller', () => { ]); }); - it('does NOT enter Semrush mode for a flat create carrying generatePrompts:false (no market)', async () => { - // Regression guard: a flat (non-Semrush) caller that defensively sends - // generatePrompts:false with no semrushMarket must be created as a plain - // brand — never pulled into Semrush provisioning. + it('enters bare Semrush mode for a serenity-active create with generatePrompts:false and no market', async () => { + // LLMO-6405: generatePrompts:false no longer means "flat" in a serenity-active + // org — the org flag decides the mode, so a market-less create provisions a bare + // sub-workspace (no project), never the market-based project provisioner. + const upsertStub = sinon.stub().resolves({ id: BRAND_UUID, name: 'Bare Brand' }); + const provisionStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-x' }); + const bareStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-bare' }); + const controller = await buildCreateController({ + upsertBrand: upsertStub, + provisionBrandSubworkspace: provisionStub, + provisionBrandSubworkspaceBare: bareStub, + }); + + const response = await controller.createBrandForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID }, + data: { + name: 'Bare Brand', + urls: [{ value: 'https://bare.com' }], + baseSiteId: 'site-b', + generatePrompts: false, + }, + dataAccess: mockDataAccess, + attributes: { authInfo: { getType: () => 'ims', profile: { email: 'user@test.com' } } }, + }); + + expect(response.status).to.equal(201); + expect(provisionStub.called).to.equal(false); + expect(bareStub.called).to.equal(true); + expect(upsertStub.firstCall.firstArg.semrushSubWorkspaceId).to.equal('ws-bare'); + expect(upsertStub.firstCall.firstArg.brand.pendingSemrushProvisioning).to.equal(undefined); + }); + + it('flat-creates (no 403) a non-serenity org sending a bare generatePrompts:false', async () => { + // A bare generatePrompts:false from a flat caller is harmless and must NOT be + // pulled into provisioning (or rejected) — it stays a plain brand. const upsertStub = sinon.stub().resolves({ id: BRAND_UUID, name: 'Flat Brand' }); const provisionStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-x' }); + const bareStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-bare' }); const controller = await buildCreateController({ upsertBrand: upsertStub, provisionBrandSubworkspace: provisionStub, + provisionBrandSubworkspaceBare: bareStub, + isSerenityActiveForOrg: sinon.stub().resolves(false), }); const response = await controller.createBrandForOrg({ @@ -5599,11 +5683,30 @@ describe('Brands Controller', () => { expect(response.status).to.equal(201); expect(provisionStub.called).to.equal(false); - // Plain row written, not bound to any Semrush workspace. + expect(bareStub.called).to.equal(false); expect(upsertStub.firstCall.firstArg.semrushSubWorkspaceId).to.equal(null); expect(upsertStub.firstCall.firstArg.brand.pendingSemrushProvisioning).to.equal(undefined); }); + it('403s a non-serenity org that supplies a semrushMarket', async () => { + const upsertStub = sinon.stub().resolves({ id: BRAND_UUID, name: 'X' }); + const controller = await buildCreateController({ + upsertBrand: upsertStub, + isSerenityActiveForOrg: sinon.stub().resolves(false), + }); + + const response = await controller.createBrandForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID }, + data: { name: 'X', semrushMarket: { market: 'us', languageCode: 'en' } }, + dataAccess: mockDataAccess, + attributes: { authInfo: { getType: () => 'ims', profile: { email: 'user@test.com' } } }, + }); + + expect(response.status).to.equal(403); + expect(upsertStub.called).to.equal(false); + }); + it('STILL enters Semrush mode for a PENDING draft with generatePrompts:false and no market', async () => { // The boolean-presence signal is preserved for drafts: a pending brand // with generatePrompts:false and no market is a legitimate diff --git a/test/controllers/serenity.test.js b/test/controllers/serenity.test.js index 40f86d761c..b9012b9c1b 100644 --- a/test/controllers/serenity.test.js +++ b/test/controllers/serenity.test.js @@ -1647,7 +1647,9 @@ describe('SerenityController', () => { it('activate ensures the subworkspace ONCE for the batch and creates each market against it', async () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); - const brand = makeBrandModel(); + // Body-driven market provisioning runs ONLY for an already-active brand + // (reactivation); a pending brand activates sub-workspace-only (LLMO-6405). + const brand = makeBrandModel({ getStatus: () => 'active' }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ brand, @@ -1672,7 +1674,7 @@ describe('SerenityController', () => { it('activate mirrors the brand domain as a Site once (not per market) when any market goes live', async () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); - const brand = makeBrandModel(); + const brand = makeBrandModel({ getStatus: () => 'active' }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ brand, @@ -1685,18 +1687,19 @@ describe('SerenityController', () => { expect(opts).to.include({ organizationId: ORG, brandId: BRAND, domain: 'x.com' }); }); - it('activate does NOT mirror a Site (or flip active) when no market goes live — stays pending with a 502', async () => { + it('activate does NOT mirror a Site (or downgrade) when no market goes live on an active brand — 207, stays active', async () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 502, body: { error: 'serenityUpstreamError' } }); - const brand = makeBrandModel(); + const brand = makeBrandModel({ getStatus: () => 'active' }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ brand, data: { brandDomain: 'x.com', brandNames: ['X'], markets: [{ market: 'us', languageCode: 'en' }] }, })); - // All-or-nothing: a market failed → no site mirror, brand stays pending, 502. - expect(response.status).to.equal(502); + // A market failed → allMarketsLive false → no site mirror. An already-active + // brand is never downgraded: 207 Multi-Status, stays active. + expect(response.status).to.equal(207); const { status } = await readBody(response); - expect(status).to.equal('pending'); + expect(status).to.equal('active'); expect(ensureMarketSiteStub).to.not.have.been.called; expect(brand.setStatus).to.not.have.been.called; }); @@ -1705,6 +1708,7 @@ describe('SerenityController', () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const setPendingSemrushProvisioning = sinon.stub(); const brand = makeBrandModel({ + getStatus: () => 'active', getPendingSemrushProvisioning: () => ({ primaryUrl: 'https://acme.com/path', markets: [{ market: 'us', languageCode: 'en' }], @@ -1712,8 +1716,8 @@ describe('SerenityController', () => { setPendingSemrushProvisioning, }); const controller = SerenityController({ env: {} }, fakeLog(), {}); - // A pending (draft) brand activated from the wizard: body carries no - // markets and no brandDomain — both come from the stash. + // An already-active brand reactivated with the leftover stash: body carries + // no markets and no brandDomain — both come from the stash. const response = await controller.activate(fakeContext({ brand, data: { brandNames: ['X'] }, @@ -1739,6 +1743,7 @@ describe('SerenityController', () => { it('activate passes each stashed market\'s modelIds into the options arg (LLMs applied at activation)', async () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const brand = makeBrandModel({ + getStatus: () => 'active', getPendingSemrushProvisioning: () => ({ primaryUrl: 'https://acme.com', markets: [{ market: 'us', languageCode: 'en', modelIds: ['chatgpt', 'perplexity'] }], @@ -1756,15 +1761,16 @@ describe('SerenityController', () => { expect(options.publishMode).to.equal('require'); }); - it('activate 400s a pending brand with no primary URL (an active brand must be site-anchored), ignoring any stashed market', async () => { - // Invariant: only a pending draft may be site-less; an active brand must - // always have a primary site (brands.site_id). So a pending brand with no - // primary URL cannot be activated — it stays pending and 400s, even if a - // market is stashed (there is no domain to anchor on / provision against). + it('activate of a pending brand WITH a stashed market is still sub-workspace-only (stash ignored, no project)', async () => { + // LLMO-6405: markets are Semrush projects added afterwards from the Markets + // tab, never auto-created at activation. A pending brand activates to just + // its sub-workspace + a status flip, IGNORING any stashed market — no + // project is provisioned. The stash is cleared on success. handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const setPendingSemrushProvisioning = sinon.stub(); const brand = makeBrandModel({ getPendingSemrushProvisioning: () => ({ + primaryUrl: 'https://acme.com', markets: [{ market: 'us', languageCode: 'en' }], }), setPendingSemrushProvisioning, @@ -1774,18 +1780,25 @@ describe('SerenityController', () => { brand, data: { brandNames: ['X'] }, })); - expect(response.status).to.equal(400); - // Rejected before any provisioning; the brand is never flipped or cleared. - expect(ensureSubworkspaceStub).to.not.have.been.called; + expect(response.status).to.equal(200); + const { status, markets } = await readBody(response); + expect(status).to.equal('active'); + expect(markets).to.deep.equal([]); + // Sub-workspace ensured exactly once; NO project created despite the stash. + expect(ensureSubworkspaceStub).to.have.been.calledOnce; expect(handlers.handleCreateMarketSubworkspace).to.not.have.been.called; + // Pending flip persists via the model (setStatus + save), not updateBrand. expect(updateBrandStub).to.not.have.been.called; - expect(brand.setStatus).to.not.have.been.calledWith('active'); - expect(setPendingSemrushProvisioning).to.not.have.been.called; + expect(brand.setStatus).to.have.been.calledWith('active'); + expect(brand.save).to.have.been.calledOnce; + // The leftover stash is cleared on success (it had one). + expect(setPendingSemrushProvisioning).to.have.been.calledWith(null); }); it('activate 400s when the stashed primary URL is unparseable and the body omits brandDomain', async () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const brand = makeBrandModel({ + getStatus: () => 'active', // An unparseable primaryUrl makes new URL() throw → hostnameFromUrlString // returns null → no domain → 400 rather than a null propagating upstream. getPendingSemrushProvisioning: () => ({ @@ -1806,6 +1819,7 @@ describe('SerenityController', () => { it('activate threads generateTopics + topicCap when the stash opts into prompts', async () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const brand = makeBrandModel({ + getStatus: () => 'active', getPendingSemrushProvisioning: () => ({ primaryUrl: 'https://acme.com', markets: [{ market: 'us', languageCode: 'en' }], @@ -1826,6 +1840,7 @@ describe('SerenityController', () => { it('activate lets generatePrompts in the body override a stash that opted out', async () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const brand = makeBrandModel({ + getStatus: () => 'active', getPendingSemrushProvisioning: () => ({ primaryUrl: 'https://acme.com', markets: [{ market: 'us', languageCode: 'en' }], @@ -1845,6 +1860,7 @@ describe('SerenityController', () => { it('activate 400s when generatePrompts is true but there is no primary URL (nothing to generate into)', async () => { const brand = makeBrandModel({ + getStatus: () => 'active', getPendingSemrushProvisioning: () => ({ markets: [], generatePrompts: false }), setPendingSemrushProvisioning: sinon.stub(), }); @@ -1858,20 +1874,46 @@ describe('SerenityController', () => { expect(ensureSubworkspaceStub).to.not.have.been.called; }); - it('activate rejects a URL-less pending brand before any upstream side-effects (no sub-workspace ensured)', async () => { - // The URL-required gate fires before ensureSubworkspace, so a rejected - // activation leaves nothing provisioned upstream — idempotent to retry once - // a primary URL is added. + it('activate of a pending brand with an empty body is sub-workspace-only (200, no project, no stash to clear)', async () => { + // LLMO-6405: a pending brand activates to just its sub-workspace + a status + // flip. The wizard supplies no markets/URL; none are needed — no project is + // created. With no stash, the stash clear is skipped. + handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const setPendingSemrushProvisioning = sinon.stub(); - const brand = makeBrandModel({ - getPendingSemrushProvisioning: () => ({ markets: [], generatePrompts: false }), - setPendingSemrushProvisioning, - }); + const brand = makeBrandModel({ setPendingSemrushProvisioning }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ brand, data: { brandNames: ['X'] } })); - expect(response.status).to.equal(400); - expect(ensureSubworkspaceStub).to.not.have.been.called; + expect(response.status).to.equal(200); + const { status, markets } = await readBody(response); + expect(status).to.equal('active'); + expect(markets).to.deep.equal([]); + // Sub-workspace ensured once; NO project, NO body-driven market path. + expect(ensureSubworkspaceStub).to.have.been.calledOnce; + expect(handlers.handleCreateMarketSubworkspace).to.not.have.been.called; expect(updateBrandStub).to.not.have.been.called; + expect(brand.setStatus).to.have.been.calledWith('active'); + expect(brand.save).to.have.been.calledOnce; + // No stash → the model's stash-clear setter is never touched. + expect(setPendingSemrushProvisioning).to.not.have.been.called; + }); + + it('activate of a pending brand returns 502 (stays pending) when the sub-workspace flip fails to persist', async () => { + // Sub-workspace ensured upstream but the active-status save diverges → the + // brand stays pending; a retry converges (the sub-workspace 409s idempotently). + handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); + const brand = makeBrandModel({ save: sinon.stub().rejects(new Error('db down')) }); + const log = fakeLog(); + const controller = SerenityController({ env: {} }, log, {}); + const response = await controller.activate(fakeContext({ brand, data: { brandNames: ['X'] } })); + expect(response.status).to.equal(502); + const { status, error, markets } = await readBody(response); + expect(status).to.equal('pending'); + expect(error).to.equal('serenityActivationIncomplete'); + expect(markets).to.deep.equal([]); + expect(ensureSubworkspaceStub).to.have.been.calledOnce; + expect(handlers.handleCreateMarketSubworkspace).to.not.have.been.called; + // Distinct, greppable token so the orphaned status is alertable. + expect(log.error).to.have.been.calledWithMatch('SERENITY_ACTIVATE_SAVE_DIVERGENCE'); }); it('activate sub-workspace-only on an already-active brand returns 207 (not downgraded) when the save fails', async () => { @@ -1892,6 +1934,7 @@ describe('SerenityController', () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const setPendingSemrushProvisioning = sinon.stub(); const brand = makeBrandModel({ + getStatus: () => 'active', getPendingSemrushProvisioning: () => ({ primaryUrl: 'https://stash.com', markets: [{ market: 'us', languageCode: 'en' }], @@ -1913,16 +1956,17 @@ describe('SerenityController', () => { expect(updateBrandStub.firstCall.args[0].updates.pendingSemrushProvisioning).to.equal(null); }); - it('activate keeps the FULL stash and stays pending when any market fails (all-or-nothing, no partial trim)', async () => { - // Stash has two draft markets; the first provisions (201), the second - // throws. All-or-nothing: the brand does NOT flip active and the stash is - // NOT trimmed — the whole blob is kept intact so a retry re-runs the full - // batch (the live market 409s; the failed one retries). + it('activate keeps the FULL stash and stays active (207) when any market fails on reactivation (no partial trim)', async () => { + // Reactivation of an active brand with two leftover stash markets; the first + // provisions (201), the second throws. All-or-nothing: the stash is NOT + // trimmed/cleared (that only happens on full success via updateBrand) and the + // already-active brand is NOT downgraded — it stays active and reports 207. handlers.handleCreateMarketSubworkspace .onFirstCall().resolves({ status: 201, body: {} }) .onSecondCall().rejects(new ErrorWithStatusCode('upstream boom', 502)); const setPendingSemrushProvisioning = sinon.stub(); const brand = makeBrandModel({ + getStatus: () => 'active', getPendingSemrushProvisioning: () => ({ primaryUrl: 'https://acme.com', markets: [{ market: 'us', languageCode: 'en' }, { market: 'de', languageCode: 'de' }], @@ -1935,11 +1979,12 @@ describe('SerenityController', () => { brand, data: { brandNames: ['X'] }, })); - // One live, one failed → activation incomplete → 502, brand stays pending. - expect(response.status).to.equal(502); + // One live, one failed → not fully succeeded → 207, brand stays active. + expect(response.status).to.equal(207); const { status } = await readBody(response); - expect(status).to.equal('pending'); - // Stash untouched (no trim, no clear) and the brand never flips active. + expect(status).to.equal('active'); + // Stash untouched (no trim, no clear via updateBrand) — the brand is not re-flipped. + expect(updateBrandStub).to.not.have.been.called; expect(setPendingSemrushProvisioning).to.not.have.been.called; expect(brand.setStatus).to.not.have.been.calledWith('active'); }); @@ -1947,7 +1992,7 @@ describe('SerenityController', () => { it('activate does NOT clear a stash on a brand that has none (no setPendingSemrushProvisioning call)', async () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const setPendingSemrushProvisioning = sinon.stub(); - const brand = makeBrandModel({ setPendingSemrushProvisioning }); + const brand = makeBrandModel({ getStatus: () => 'active', setPendingSemrushProvisioning }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ brand, @@ -1962,7 +2007,7 @@ describe('SerenityController', () => { getBrandAliasesStub.resolves(['Acme Inc']); const controller = SerenityController({ env: {} }, fakeLog(), {}); const ctx = fakeContext({ - brand: makeBrandModel(), + brand: makeBrandModel({ getStatus: () => 'active' }), data: { brandDomain: 'x.com', brandNames: ['X'], markets: [{ market: 'us', languageCode: 'en' }, { market: 'de', languageCode: 'de' }] }, }); const response = await controller.activate(ctx); @@ -1998,7 +2043,7 @@ describe('SerenityController', () => { getBrandUrlSourcesStub.resolves(sources); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ - brand: makeBrandModel(), + brand: makeBrandModel({ getStatus: () => 'active' }), data: { brandDomain: 'x.com', brandNames: ['X'], markets: [{ market: 'us', languageCode: 'en' }, { market: 'de', languageCode: 'de' }] }, })); expect(response.status).to.equal(200); @@ -2014,7 +2059,7 @@ describe('SerenityController', () => { getBrandCompetitorsStub.resolves(competitors); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ - brand: makeBrandModel(), + brand: makeBrandModel({ getStatus: () => 'active' }), data: { brandDomain: 'x.com', brandNames: ['X'], markets: [{ market: 'us', languageCode: 'en' }, { market: 'de', languageCode: 'de' }] }, })); expect(response.status).to.equal(200); @@ -2030,6 +2075,7 @@ describe('SerenityController', () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ + brand: makeBrandModel({ getStatus: () => 'active' }), data: { markets: [], brandDomain: 'x.com', brandNames: ['X'] }, })); expect(response.status).to.equal(200); @@ -2042,8 +2088,9 @@ describe('SerenityController', () => { it('activate 400s when the markets array exceeds the cap (and does not provision)', async () => { const markets = Array.from({ length: 51 }, (_, i) => ({ market: 'us', languageCode: `l${i}` })); const controller = SerenityController({ env: {} }, fakeLog(), {}); - // A brandDomain routes to the project path where the cap is enforced. + // An active brand + a brandDomain routes to the project path where the cap is enforced. const response = await controller.activate(fakeContext({ + brand: makeBrandModel({ getStatus: () => 'active' }), data: { markets, brandDomain: 'x.com', brandNames: ['X'] }, })); expect(response.status).to.equal(400); @@ -2051,15 +2098,15 @@ describe('SerenityController', () => { expect(handlers.handleCreateMarketSubworkspace).to.not.have.been.called; }); - it('activate records a thrown market as failed without aborting the batch, but stays pending (all-or-nothing)', async () => { + it('activate records a thrown market as failed without aborting the batch, staying active (207) on reactivation', async () => { // Market 1 publishes (201, live upstream); market 2 throws. The batch must - // NOT abort - both markets are reported per-market. But all-or-nothing means - // a single failure keeps the brand pending and returns a 502 (the live - // market 409s on the next retry, which converges). + // NOT abort - both markets are reported per-market. A single failure means + // the reactivation is not fully successful, but an already-active brand is + // never downgraded: it stays active and returns 207 Multi-Status. handlers.handleCreateMarketSubworkspace .onFirstCall().resolves({ status: 201, body: {} }) .onSecondCall().rejects(new ErrorWithStatusCode('upstream boom', 502)); - const brand = makeBrandModel(); + const brand = makeBrandModel({ getStatus: () => 'active' }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ brand, @@ -2069,23 +2116,23 @@ describe('SerenityController', () => { markets: [{ market: 'us', languageCode: 'en' }, { market: 'de', languageCode: 'de' }], }, })); - // incomplete activation -> 502, brand stays pending. - expect(response.status).to.equal(502); + // incomplete reactivation -> 207, brand stays active. + expect(response.status).to.equal(207); const { status, markets } = await readBody(response); - expect(status).to.equal('pending'); + expect(status).to.equal('active'); // both markets reported; the throwing one becomes a 502 entry, no URL leak. expect(markets).to.have.length(2); expect(markets[0].status).to.equal(201); expect(markets[1].status).to.equal(502); expect(markets[1].body.message).to.equal('Market activation failed'); - // Brand never flips active and is not re-saved on the failure path. + // Brand is neither re-flipped nor re-saved on the partial-failure path. expect(brand.setStatus).to.not.have.been.calledWith('active'); expect(brand.save).to.not.have.been.called; }); it('activate defaults a statusless throw to 502 in the per-market result', async () => { handlers.handleCreateMarketSubworkspace.rejects(new Error('no status')); - const brand = makeBrandModel(); + const brand = makeBrandModel({ getStatus: () => 'active' }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ brand, @@ -2095,8 +2142,9 @@ describe('SerenityController', () => { markets: [{ market: 'us', languageCode: 'en' }], }, })); - // every market failed (no 201) -> 502, brand stays pending. - expect(response.status).to.equal(502); + // every market failed (no 201) -> not fully succeeded -> 207, active brand + // is not downgraded. The per-market result defaults the statusless throw to 502. + expect(response.status).to.equal(207); const { markets } = await readBody(response); expect(markets[0].status).to.equal(502); expect(brand.setStatus).to.not.have.been.called; @@ -2106,7 +2154,7 @@ describe('SerenityController', () => { handlers.handleCreateMarketSubworkspace .onFirstCall().resolves({ status: 201, body: {} }) .onSecondCall().resolves({ status: 409, body: { error: 'sliceExists' } }); - const brand = makeBrandModel(); + const brand = makeBrandModel({ getStatus: () => 'active' }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ brand, @@ -2123,19 +2171,20 @@ describe('SerenityController', () => { expect(updateBrandStub.firstCall.args[0].updates.status).to.equal('active'); }); - it('activate returns 502 and stays pending when every market genuinely fails', async () => { + it('activate returns 207 and stays active when every market genuinely fails on reactivation', async () => { // A real failure status (502), NOT 409 - a 409 sliceExists means the market // is already live and counts as success (see the all-409 re-activate test). + // The reactivation is not fully successful, but an active brand is not downgraded. handlers.handleCreateMarketSubworkspace.resolves({ status: 502, body: {} }); - const brand = makeBrandModel(); + const brand = makeBrandModel({ getStatus: () => 'active' }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ brand, data: { brandDomain: 'x.com', brandNames: ['X'], markets: [{ market: 'us', languageCode: 'en' }] }, })); - expect(response.status).to.equal(502); + expect(response.status).to.equal(207); const { status } = await readBody(response); - expect(status).to.equal('pending'); + expect(status).to.equal('active'); expect(brand.setStatus).to.not.have.been.called; }); @@ -2144,7 +2193,7 @@ describe('SerenityController', () => { // returns 409 sliceExists. That is a COMPLETE success, not a partial one - // the brand is active and the HTTP status is 200, never 207/pending. handlers.handleCreateMarketSubworkspace.resolves({ status: 409, body: { error: 'sliceExists' } }); - const brand = makeBrandModel(); + const brand = makeBrandModel({ getStatus: () => 'active' }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ brand, @@ -2193,7 +2242,7 @@ describe('SerenityController', () => { ensureMarketSiteStub.resolves('primary-site-uuid'); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ - brand: makeBrandModel(), + brand: makeBrandModel({ getStatus: () => 'active' }), data: { brandDomain: 'x.com', brandNames: ['X'], markets: [{ market: 'us', languageCode: 'en' }] }, })); expect(response.status).to.equal(200); @@ -2219,7 +2268,7 @@ describe('SerenityController', () => { updateBrandStub.rejects(conflict); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ - brand: makeBrandModel(), + brand: makeBrandModel({ getStatus: () => 'active' }), data: { brandDomain: 'x.com', brandNames: ['X'], markets: [{ market: 'us', languageCode: 'en' }] }, })); expect(response.status).to.equal(409); @@ -2229,13 +2278,13 @@ describe('SerenityController', () => { expect(message).to.equal('This site is already the primary URL for another brand'); }); - it('activate emits SERENITY_ACTIVATE_SAVE_DIVERGENCE and returns 502 (stays pending) when the status save fails', async () => { - // The markets are live + the site is linked upstream, but persisting the - // 'active' flip fails → the brand stays pending (divergence). The seam emits - // a distinct, alertable token and surfaces a 502 with the per-market results - // (not a bare 5xx via mapError that would discard them). A retry converges. + it('activate emits SERENITY_ACTIVATE_SAVE_DIVERGENCE and returns 207 (stays active) when the status save fails', async () => { + // Reactivation of an active brand: markets live + site linked upstream, but + // re-persisting the 'active' flip fails transiently (divergence). The seam + // emits a distinct, alertable token; the already-active brand is not + // downgraded, so it returns 207 with the per-market results preserved. handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); - const brand = makeBrandModel(); + const brand = makeBrandModel({ getStatus: () => 'active' }); // The active-flip write (updateBrand) fails transiently → divergence. updateBrandStub.rejects(new Error('db down')); const log = fakeLog(); @@ -2244,13 +2293,13 @@ describe('SerenityController', () => { brand, data: { brandDomain: 'x.com', brandNames: ['X'], markets: [{ market: 'us', languageCode: 'en' }] }, })); - // save diverged -> 502, brand stays pending, per-market results preserved. - expect(response.status).to.equal(502); + // save diverged -> 207, brand stays active, per-market results preserved. + expect(response.status).to.equal(207); const { status, markets } = await readBody(response); - expect(status).to.equal('pending'); + expect(status).to.equal('active'); expect(markets).to.have.length(1); expect(markets[0].status).to.equal(201); - // distinct, greppable token so the orphaned status is alertable. + // distinct, greppable token so the divergence is alertable. expect(log.error).to.have.been.calledWithMatch('SERENITY_ACTIVATE_SAVE_DIVERGENCE'); }); @@ -2261,7 +2310,7 @@ describe('SerenityController', () => { // filter), not just freshly-created 201s. handlers.handleCreateMarketSubworkspace.resolves({ status: 409, body: { error: 'sliceExists' } }); ensureMarketSiteStub.resolves('site-uuid-1'); - const brand = makeBrandModel(); + const brand = makeBrandModel({ getStatus: () => 'active' }); // The active-flip write (updateBrand) fails transiently → divergence. updateBrandStub.rejects(new Error('db down')); const log = fakeLog(); @@ -2270,8 +2319,9 @@ describe('SerenityController', () => { brand, data: { brandDomain: 'x.com', brandNames: ['X'], markets: [{ market: 'us', languageCode: 'en' }] }, })); - // Every market live (via 409) + site linked, but the active-flip save fails. - expect(response.status).to.equal(502); + // Every market live (via 409) + site linked, but the active-flip save fails; + // active brand not downgraded → 207. + expect(response.status).to.equal(207); const divergenceCall = log.error.getCalls().find( (c) => typeof c.args[0] === 'string' && c.args[0].includes('SERENITY_ACTIVATE_SAVE_DIVERGENCE'), ); @@ -2280,14 +2330,17 @@ describe('SerenityController', () => { expect(divergenceCall.args[1].marketsLive).to.equal(1); }); - it('activate stays pending with a 502 when every market is live but the brand_sites link fails', async () => { - // The brand_sites mirror (type='serenity') is a REQUIRED activation step: - // even with every market live, a failed site link keeps the brand pending so - // a retry re-establishes the link. ensureMarketSite returns null on failure. + it('activate returns 207 (stays active) when every market is live but the brand_sites link fails on reactivation', async () => { + // The brand_sites mirror (type='serenity') is a REQUIRED step for full + // success: even with every market live, a failed site link means the + // reactivation is not fully successful (updateBrand is skipped). An + // already-active brand is not downgraded → 207. ensureMarketSite returns + // null on failure. handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); ensureMarketSiteStub.resolves(null); const setPendingSemrushProvisioning = sinon.stub(); const brand = makeBrandModel({ + getStatus: () => 'active', getPendingSemrushProvisioning: () => ({ primaryUrl: 'https://acme.com', markets: [{ market: 'us', languageCode: 'en' }], @@ -2297,17 +2350,15 @@ describe('SerenityController', () => { const log = fakeLog(); const controller = SerenityController({ env: {} }, log, {}); const response = await controller.activate(fakeContext({ brand, data: { brandNames: ['X'] } })); - // Markets live but not linked → activation incomplete → 502, stays pending. - expect(response.status).to.equal(502); + // Markets live but not linked → not fully succeeded → 207, stays active. + expect(response.status).to.equal(207); const { status } = await readBody(response); - expect(status).to.equal('pending'); + expect(status).to.equal('active'); expect(ensureMarketSiteStub).to.have.been.calledOnce; - // Never flips active; the stash is preserved (not cleared) for retry. + // Not fully succeeded → not re-flipped and the stash is preserved (not cleared). + expect(updateBrandStub).to.not.have.been.called; expect(brand.setStatus).to.not.have.been.calledWith('active'); expect(setPendingSemrushProvisioning).to.not.have.been.called; - // Distinct, greppable token so the "live upstream but dark on our side" - // strand is alertable rather than hidden in a generic 502. - expect(log.error).to.have.been.calledWithMatch('SERENITY_ACTIVATE_LINK_INCOMPLETE'); }); it('activate does NOT downgrade an already-active brand on a partial failure (207, stays active)', async () => { @@ -2684,15 +2735,15 @@ describe('SerenityController', () => { // activate — `ctx.data || {}` fallback and the `Array.isArray(body.markets) // ? ... : storedMarkets` else-branch when markets is not an array. With no // primary URL these route to the sub-workspace-only activation (200). - it('activate falls back to {} body when ctx.data is absent (pending brand with no URL → 400)', async () => { + it('activate falls back to {} body when ctx.data is absent (pending brand → sub-workspace-only 200)', async () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const ctx = fakeContext(); ctx.data = undefined; const response = await controller.activate(ctx); - // ctx.data undefined is handled as {} (no crash); with no primary URL a - // pending brand cannot activate → 400. - expect(response.status).to.equal(400); + // ctx.data undefined is handled as {} (no crash); a pending brand activates + // sub-workspace-only regardless of body → 200, no project created. + expect(response.status).to.equal(200); expect(handlers.handleCreateMarketSubworkspace).to.not.have.been.called; }); @@ -2700,6 +2751,7 @@ describe('SerenityController', () => { handlers.handleCreateMarketSubworkspace.resolves({ status: 201, body: {} }); const controller = SerenityController({ env: {} }, fakeLog(), {}); const response = await controller.activate(fakeContext({ + brand: makeBrandModel({ getStatus: () => 'active' }), data: { markets: 'not-an-array', brandDomain: 'x.com', brandNames: ['X'] }, })); expect(response.status).to.equal(200); diff --git a/test/support/brands-storage.test.js b/test/support/brands-storage.test.js index da574f5b25..5c709d302e 100644 --- a/test/support/brands-storage.test.js +++ b/test/support/brands-storage.test.js @@ -1061,7 +1061,7 @@ describe('brands-storage', () => { expect(brandsUpsert.row).to.not.have.property('semrush_sub_workspace_id'); }); - it('keeps the brand active without a site_id when a semrush_sub_workspace_id anchors it', async () => { + it('keeps the brand active with a null site_id when only a semrush_sub_workspace_id anchors it (no baseSiteId)', async () => { const client = createCapturingClient({ brands: [ { data: null, error: null }, @@ -1080,10 +1080,12 @@ describe('brands-storage', () => { const brandsUpsert = client.capturedCalls.upsert.find((c) => c.table === 'brands'); expect(brandsUpsert.row.status).to.equal('active'); expect(brandsUpsert.row.semrush_sub_workspace_id).to.equal('ws-1'); - expect(brandsUpsert.row).to.not.have.property('site_id'); + // A fresh create always writes an explicit site_id — null when no baseSiteId + // is supplied. The sub-workspace is what keeps it active (LLMO-6405). + expect(brandsUpsert.row.site_id).to.equal(null); }); - it('ignores baseSiteId on a semrush-anchored create (never sets site_id, avoids 409)', async () => { + it('writes site_id from baseSiteId on a semrush-anchored create (anchored by BOTH — LLMO-6405)', async () => { const client = createCapturingClient({ brands: [ { data: null, error: null }, // no existing brand @@ -1094,15 +1096,17 @@ describe('brands-storage', () => { await upsertBrand({ organizationId: ORG_ID, - // baseSiteId would coincidentally match an onboarded site already owned by - // another brand; a semrush-anchored brand must NOT claim it as site_id. - brand: { name: 'Test', status: 'active', baseSiteId: 'collides-with-other-brand' }, + // LLMO-6405: a Semrush brand now also carries its primary site — the UI's + // primary-URL step sends baseSiteId, so site_id is populated on every path. + brand: { name: 'Test', status: 'active', baseSiteId: 'primary-site-id' }, postgrestClient: client, semrushSubWorkspaceId: 'ws-1', }); const brandsUpsert = client.capturedCalls.upsert.find((c) => c.table === 'brands'); - expect(brandsUpsert.row).to.not.have.property('site_id'); + // The old anchoredBySemrush skip is removed — site_id IS written from + // baseSiteId even when a sub-workspace anchors the brand. + expect(brandsUpsert.row.site_id).to.equal('primary-site-id'); expect(brandsUpsert.row.semrush_sub_workspace_id).to.equal('ws-1'); expect(brandsUpsert.row.status).to.equal('active'); }); diff --git a/test/support/serenity/brand-provisioning.test.js b/test/support/serenity/brand-provisioning.test.js index af9c9e2b25..710dabb938 100644 --- a/test/support/serenity/brand-provisioning.test.js +++ b/test/support/serenity/brand-provisioning.test.js @@ -466,6 +466,170 @@ describe('provisionBrandSubworkspace', () => { }); }); +describe('provisionBrandSubworkspaceBare', () => { + const TRANSPORT = { name: 'bare-transport' }; + const bareParams = { spaceCatId: 'org-1', brandId: 'brand-1', brandName: 'Acme' }; + let resolveWorkspaceId; + let ensureSubworkspace; + let deleteAllProjects; + let releaseFullAllocation; + + beforeEach(() => { + resolveWorkspaceId = sinon.stub().resolves(PARENT_WS); + // Like the real ensureSubworkspace, resolve the new sub-workspace id AND set + // it on the brand stub via setSemrushSubWorkspaceId. + ensureSubworkspace = sinon.stub().callsFake(async (transport, brand) => { + brand.setSemrushSubWorkspaceId(NEW_WS); + return NEW_WS; + }); + deleteAllProjects = sinon.stub().resolves(); + releaseFullAllocation = sinon.stub().resolves(); + }); + + async function loadBareModule() { + return esmock('../../../src/support/serenity/brand-provisioning.js', { + '../../../src/support/serenity/workspace-resolver.js': { resolveWorkspaceId }, + '../../../src/support/serenity/rest-transport.js': { + createSerenityTransport: () => TRANSPORT, + SerenityTransportError, + }, + '../../../src/support/serenity/workspace-lifecycle.js': { + ensureSubworkspace, + deleteAllProjects, + releaseFullAllocation, + }, + }); + } + + it('provisions the bare sub-workspace (marketCount 1) and returns its id — no project created', async () => { + const { provisionBrandSubworkspaceBare } = await loadBareModule(); + const result = await provisionBrandSubworkspaceBare(buildContext(), bareParams); + expect(result).to.deep.equal({ semrushSubWorkspaceId: NEW_WS }); + // Carved for a single future project (marketCount = 1), against the org parent. + expect(ensureSubworkspace).to.have.been.calledOnce; + expect(ensureSubworkspace.firstCall.args[0]).to.equal(TRANSPORT); + expect(ensureSubworkspace.firstCall.args[2]).to.equal(PARENT_WS); + expect(ensureSubworkspace.firstCall.args[3]).to.equal(1); + // Success → no allocation release. + expect(deleteAllProjects).to.not.have.been.called; + expect(releaseFullAllocation).to.not.have.been.called; + }); + + it('falls back to the captured workspace id when ensureSubworkspace returns nothing', async () => { + // Sets the stub id but returns undefined → resolved via capturedWorkspaceId. + ensureSubworkspace = sinon.stub().callsFake(async (transport, brand) => { + brand.setSemrushSubWorkspaceId(NEW_WS); + return undefined; + }); + const { provisionBrandSubworkspaceBare } = await loadBareModule(); + const result = await provisionBrandSubworkspaceBare(buildContext(), bareParams); + expect(result).to.deep.equal({ semrushSubWorkspaceId: NEW_WS }); + }); + + it('throws 502 when neither a returned nor a captured sub-workspace id is available', async () => { + ensureSubworkspace = sinon.stub().resolves(undefined); // never sets the stub id + const { provisionBrandSubworkspaceBare } = await loadBareModule(); + try { + await provisionBrandSubworkspaceBare(buildContext(), bareParams); + expect.fail('should have thrown'); + } catch (e) { + expect(e.status).to.equal(502); + expect(e.message).to.equal('Semrush provisioning returned no sub-workspace id'); + } + expect(deleteAllProjects).to.not.have.been.called; + }); + + it('releases the captured sub-workspace allocation when ensureSubworkspace throws after creating it', async () => { + ensureSubworkspace = sinon.stub().callsFake(async (transport, brand) => { + brand.setSemrushSubWorkspaceId(NEW_WS); // created upstream... + throw new SerenityTransportError(502, 'settle timeout'); // ...then failed + }); + const { provisionBrandSubworkspaceBare } = await loadBareModule(); + const log = { info: sinon.stub(), error: sinon.stub() }; + try { + await provisionBrandSubworkspaceBare(buildContext(), bareParams, log); + expect.fail('should have thrown'); + } catch (e) { + expect(e.status).to.equal(502); + } + // The orphaned (empty) sub-workspace's projects are emptied + allocation floored. + expect(deleteAllProjects).to.have.been.calledOnceWithExactly(TRANSPORT, NEW_WS); + expect(releaseFullAllocation).to.have.been.calledOnce; + expect(releaseFullAllocation.firstCall.args).to.deep.equal([TRANSPORT, NEW_WS, PARENT_WS, log]); + expect(log.info).to.have.been.called; + }); + + it('does NOT attempt a release when ensureSubworkspace throws before creating the sub-workspace', async () => { + ensureSubworkspace = sinon.stub().rejects(new SerenityTransportError(500, 'early boom')); + const { provisionBrandSubworkspaceBare } = await loadBareModule(); + try { + await provisionBrandSubworkspaceBare(buildContext(), bareParams); + expect.fail('should have thrown'); + } catch (e) { + expect(e.status).to.equal(500); + } + expect(deleteAllProjects).to.not.have.been.called; + expect(releaseFullAllocation).to.not.have.been.called; + }); + + it('swallows a release failure (logs at error) and re-throws the original error', async () => { + ensureSubworkspace = sinon.stub().callsFake(async (transport, brand) => { + brand.setSemrushSubWorkspaceId(NEW_WS); + throw new SerenityTransportError(502, 'settle timeout'); + }); + deleteAllProjects = sinon.stub().rejects(new Error('release network error')); + const { provisionBrandSubworkspaceBare } = await loadBareModule(); + const log = { info: sinon.stub(), error: sinon.stub() }; + try { + await provisionBrandSubworkspaceBare(buildContext(), bareParams, log); + expect.fail('should have thrown'); + } catch (e) { + // The ORIGINAL error is re-thrown, not the release failure. + expect(e.status).to.equal(502); + } + expect(log.error).to.have.been.called; + const [msg, meta] = log.error.firstCall.args; + expect(msg).to.include('failed to release'); + expect(meta.semrushWorkspaceId).to.equal(NEW_WS); + expect(meta.error).to.equal('release network error'); + }); + + it('throws 400 when brandName is missing', async () => { + const { provisionBrandSubworkspaceBare } = await loadBareModule(); + try { + await provisionBrandSubworkspaceBare(buildContext(), { ...bareParams, brandName: '' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e.status).to.equal(400); + expect(e.message).to.equal('brandName is required for Semrush provisioning'); + } + }); + + it('throws 400 when brandId is missing', async () => { + const { provisionBrandSubworkspaceBare } = await loadBareModule(); + try { + await provisionBrandSubworkspaceBare(buildContext(), { ...bareParams, brandId: '' }); + expect.fail('should have thrown'); + } catch (e) { + expect(e.status).to.equal(400); + expect(e.message).to.equal('brandId is required for Semrush provisioning'); + } + }); + + it('throws 400 when the org has no parent Semrush workspace', async () => { + resolveWorkspaceId = sinon.stub().resolves(null); + const { provisionBrandSubworkspaceBare } = await loadBareModule(); + try { + await provisionBrandSubworkspaceBare(buildContext(), bareParams); + expect.fail('should have thrown'); + } catch (e) { + expect(e.status).to.equal(400); + expect(e.message).to.equal('Organization has no Semrush workspace configured'); + } + expect(ensureSubworkspace).to.not.have.been.called; + }); +}); + describe('releaseProvisionedWorkspace', () => { function buildAuthedContext(extraEnv = {}) { return { From da25b745d1146cce2844fae544a0bea5925bbd78 Mon Sep 17 00:00:00 2001 From: Igor Grubic Date: Tue, 21 Jul 2026 17:15:17 +0200 Subject: [PATCH 2/3] docs(serenity): update activate + brand-create contract for the market-management rework (LLMO-6405) - serenity.md: create mode is decided by the org serenity flag (not the body); a market-less serenity create provisions a bare sub-workspace; a pending brand activates sub-workspace-only (no market, no stash-driven provisioning); an already-active brand's reactivation is body-driven and never downgraded (207); brands.site_id anchors the brand from baseSiteId; site-mirror lifecycle updated. - openapi/schemas.yaml: add baseSiteId to V2BrandInput; rewrite the SerenityActivateRequest description (pending = sub-workspace-only, ignores markets/stash; active = body-driven) and the generatePrompts/modelIds field docs. docs:lint passes (API description valid). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi/schemas.yaml | 46 ++++++++++++++----------- docs/serenity.md | 72 +++++++++++++++++++++------------------ 2 files changed, 65 insertions(+), 53 deletions(-) diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index 781597e454..3285ca2fe5 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -7857,6 +7857,14 @@ V2BrandInput: type: array items: $ref: '#/V2BrandCompetitor' + baseSiteId: + type: string + format: uuid + description: >- + Id of the brand's primary SpaceCat Site — an existing onboarded site or one + onboarded inline. Written to `brands.site_id` as the brand's primary-URL + anchor. Sent on every create (a Serenity brand is anchored by BOTH its + sub-workspace and this primary Site). V2BrandUpdateInput: type: object @@ -11280,25 +11288,27 @@ SerenityMarketDetail: SerenityActivateRequest: type: object description: | - Activates a brand into sub-workspace mode: ensures the brand's own - Semrush sub-workspace exists (creating + resourcing it on first call), - then per supplied market creates-or-resumes a draft and publishes it once. - - All fields are optional. A pending (draft) brand activated from the wizard - sends an EMPTY body `{}` and every value is read from its stashed - `pending_semrush_provisioning` (primary URL, markets, generatePrompts). A - reactivation of an already-live brand re-supplies them in the body (the body - wins when present; there is no stored market memory otherwise). When no - primary URL/domain is available at all, a sub-workspace-only brand is - activated (no project). With a domain but no market, a single US/EN fallback - project is provisioned. + Activates a brand into sub-workspace mode. + + A PENDING (draft) brand activates SUB-WORKSPACE-ONLY: it ensures the brand's + own Semrush sub-workspace and flips the brand active, with NO project. The + wizard's approve sends an EMPTY body `{}`; any `markets`/`brandDomain` (or the + legacy `pending_semrush_provisioning` stash) are IGNORED for provisioning. The + brand is anchored by its primary site (`brands.site_id`, set at create), and + markets are added afterwards from the Markets tab. + + An ALREADY-ACTIVE brand (reactivation / onboarding API) provisions the markets + supplied in the body: per market it creates-or-resumes a draft project and + publishes it once. With a `brandDomain` but no `markets`, a single US/EN + fallback project is provisioned. An active brand is never downgraded on a + partial failure (207 Multi-Status). All fields are optional. properties: generatePrompts: type: boolean description: >- - Whether to generate topics/prompts for the provisioned project(s). - Overrides the stashed value; defaults to the stash (else false). Requires - a primary URL/domain (a project) — true with no domain is rejected (400). + Whether to generate topics/prompts for a supplied market's project + (already-active brand only). Ignored for a pending brand, which activates + sub-workspace-only with no project. brandDomain: type: string description: Primary domain for the brand's upstream projects. @@ -11337,10 +11347,8 @@ SerenityActivateRequest: items: type: string description: >- - Optional AI model (LLM) ids to attach to this market's project. - When omitted here, the markets stashed on the pending brand supply - them (a draft activated from the wizard carries per-market LLMs in - pending_semrush_provisioning). + Optional AI model (LLM) ids to attach to this market's project + (already-active brand reactivation). SerenityActivateResponse: type: object diff --git a/docs/serenity.md b/docs/serenity.md index 5af11d70ce..01dab7a186 100644 --- a/docs/serenity.md +++ b/docs/serenity.md @@ -115,10 +115,14 @@ them before a workspace (or the flag) exists. The same helper gates the Semrush **side-effects** on the v2 brand endpoints (`src/controllers/brands.js`), so an inactive org operates as plain backend CRUD: -- `POST /v2/orgs/:org/brands` — a **Semrush-mode** create (`semrushMarket` / - `generatePrompts`, i.e. one that would provision a sub-workspace) is rejected - with `403 Serenity is not active for this organization` while the flag is off. - A plain (flat) create is unaffected. +- `POST /v2/orgs/:org/brands` — **Semrush mode is decided by the org serenity flag, + not the request body** (LLMO-6405). In a serenity-active org every brand create is + a Semrush create: it provisions the brand's sub-workspace — a **bare** sub-workspace + when no market is supplied (markets are added later from the Markets tab), or the + initial project when a `semrushMarket` is supplied. While the flag is **off**, a + create that carries a `semrushMarket` or `generatePrompts: true` is rejected with + `403 Serenity is not active for this organization`; a plain (flat) create — + including a bare `generatePrompts: false` — is unaffected. - `PATCH /v2/orgs/:org/brands/:brandId` — an edit that would **re-sync to Semrush** (URL / competitor / alias change on a brand that has a `semrush_workspace_id`) is rejected `403` while the flag is off, before the @@ -148,7 +152,7 @@ All endpoints require `Authorization: Bearer ` and `organization | PATCH | `/serenity/tags/:tagId` | Rename and/or re-parent a tag by its upstream id. `name` is a bare value. `parentId`: an id RE-PARENTS within the tag's own dimension, omitted preserves the current parent, and an explicit `null` is rejected — the root level is reserved for the four dimension roots. The new parent may be neither the tag itself nor one of its descendants (400): upstream stores a parent pointer rather than a tree and would accept the edge, leaving the tag's subtree reachable from no root, and so unreachable and unrepairable through this API. The proxy always re-sends a parent upstream, because a PATCH that omits one promotes the tag to a root. A dimension root (400), a closed dimension's value (400), and an unknown id (404 `tagNotFound`) are all refused. | `updateSerenityTag` | | GET | `/serenity/models?geoTargetId=&languageCode=` | AI models for one slice (catalog mode when no params) | `listSerenityModels` | | PUT | `/serenity/models` | Replace the AI-model set for one slice (publishes after change) | `updateSerenityModels` | -| POST | `/serenity/activate` | Activate the brand into sub-workspace mode (ensure sub-workspace + publish supplied markets) | `activateSerenityBrand` | +| POST | `/serenity/activate` | Activate into sub-workspace mode. A **pending** brand activates sub-workspace-only (ensure sub-workspace + flip active, no markets); an already-**active** brand's body-supplied markets are provisioned (reactivation) | `activateSerenityBrand` | | POST | `/serenity/deactivate` | Deactivate: decommission the sub-workspace + disconnect the brand back to flat mode | `deactivateSerenityBrand` | The above are prefixed with `/v2/orgs/:spaceCatId/brands/:brandId`. @@ -207,8 +211,8 @@ For backwards compatibility and integrations, every Semrush market (project) is - The Site is linked to the owning brand via a **`brand_sites` row tagged `type='serenity'`** (`src/support/serenity/site-linkage.js` → `ensureMarketSite`; the marker names the owning feature, not the provider). The marker is load-bearing: - **`syncBrandSites` preserves it.** That function rebuilds `brand_sites` from `brand.urls` on every brand edit (delete-all-then-reinsert). A market's domain is generally **not** in `brand.urls`, so an unmarked row would be silently deleted on the next edit. The marker excludes these rows from the delete and keeps their type from being downgraded on re-upsert. - **`mapDbBrandToV2` excludes it.** A market's domain is not a brand URL, so `type='serenity'` rows never surface in the brand V2 response (`urls[]` / `siteIds`). Integrations resolve them via the `sites` / `brand_sites` tables directly. -- **Lifecycle:** the Site (+ link) is ensured on **brand creation** (the provisioned domain), **activation** (the activated markets' domain), and **market creation in sub-workspace mode** (that market's domain — `ensureMarketSite` runs only on the `subworkspace` branch of `POST /serenity/markets`). A **flat-mode** brand is **not** mirrored on market creation; it gets a Site only once activation promotes it to sub-workspace mode. The Site is **never auto-deleted** — market deletion leaves the Site and its link in place. A market-mirror site's **`baseURL` is immutable at the API**: a `PATCH /sites` that changes it is rejected (the domain is the Semrush project anchor; `isSemrushMarketMirrorSite` gates the guard). -- **Best-effort, except on activation:** on the brand-create and market-create paths the Semrush project is the primary outcome and has already succeeded when mirroring runs, so a Site/link failure is logged and swallowed (never fails a live market). On **activation** the link is instead a **required** step — if the markets are live but the mirror fails, the brand stays `pending` and returns 502 (see Activate below). `Site.create` uses `deliveryType: 'other'` (not an AEM target). +- **Lifecycle:** the market Site (+ link) is ensured wherever a **market** is provisioned — a **brand create that supplies a market** (that market's domain), an **already-active brand's activation** (the activated markets' domain), and **market creation in sub-workspace mode** (that market's domain — `ensureMarketSite` runs only on the `subworkspace` branch of `POST /serenity/markets`). A **bare** brand create (no market — the LLMO-6405 default) and a **pending** brand's sub-workspace-only activation mirror **no** market Site; the brand's own primary site is recorded as `brands.site_id` from the selected `baseSiteId` at create (a Semrush brand is anchored by BOTH its sub-workspace and its primary Site). A **flat-mode** brand is **not** mirrored on market creation. The Site is **never auto-deleted** — market deletion leaves the Site and its link in place. A market-mirror site's **`baseURL` is immutable at the API**: a `PATCH /sites` that changes it is rejected (the domain is the Semrush project anchor; `isSemrushMarketMirrorSite` gates the guard). +- **Best-effort, except on active-brand activation:** on the brand-create and market-create paths the Semrush project is the primary outcome and has already succeeded when mirroring runs, so a Site/link failure is logged and swallowed (never fails a live market). On an **already-active brand's activation** (reactivation) the site mirror is part of the all-or-nothing success gate — if the markets are live but the mirror fails, the brand is not marked fully-succeeded and returns **207** (it stays `active`, never downgraded; see Activate below). `Site.create` uses `deliveryType: 'other'` (not an AEM target). ## Activate / deactivate (sub-workspace dual-mode) @@ -216,42 +220,42 @@ A brand runs in one of two modes, decided entirely by `brands.semrush_workspace_ - **flat** (pointer NULL): markets resolve through the shared org parent workspace via the `BrandSemrushProject` mapping. - **subworkspace** (pointer set): the brand has its own Semrush sub-workspace; markets resolve live from it via `listProjects`. -`POST /serenity/activate` moves a brand into sub-workspace mode. Two shapes: +`POST /serenity/activate` moves a brand into sub-workspace mode. There are two +paths, keyed on the brand's current status (LLMO-6405): -- **Sub-workspace-only (no resolved `brandDomain`):** a brand with no primary URL - has nothing to provision a project against, so activate only ensures the - sub-workspace (which IS the active-brand anchor) and flips the brand active — - **HTTP 200 with an empty `markets[]`**, no project, no Site mirror. The user - adds markets later from the Markets tab. `generatePrompts` is rejected here - (no project to attach prompts to). NOTE: this is the only path that does NOT - require `brandDomain` — see the resolution rules below. -- **Project activation (resolved `brandDomain`):** +**Pending brand → sub-workspace-only.** Activating a *pending* (draft) brand ONLY +ensures its Semrush sub-workspace (the active-brand anchor), clears any legacy +provisioning stash, and flips the brand `active` — **HTTP 200 with an empty +`markets[]`**, no project, no Site mirror. Markets are Semrush projects the user +adds afterwards from the Markets tab (the wizard's approve sends an empty body). +Any `markets` / `brandDomain` / stash on a pending brand is **ignored for +provisioning** — the brand is anchored by its primary site (`brands.site_id`, set at +create). If the sub-workspace is ensured upstream but the `active` flip fails to +persist, the brand stays `pending` and returns **502 `serenityActivationIncomplete`** +(idempotent to retry — the sub-workspace 409s on the next attempt). + +**Already-active brand → body-driven market provisioning (reactivation / onboarding +API).** For a brand that is already `active`, the body's markets are provisioned: ``` 1. ensure the sub-workspace ONCE for the whole batch (create + settle, or re-grant) -2. for each market (resolved below; empty -> a single US/en fallback project): - create-or-resume a draft project, attach models + generated topic prompts + - brand URLs + competitor benchmarks, then publish +2. for each market (from the body; empty + a resolved brandDomain -> one US/en + fallback project): create-or-resume a draft project, attach models + generated + topic prompts + brand URLs + competitor benchmarks, then publish 3. mirror every live market as a Site + brand_sites row (type='serenity') -4. ALL-OR-NOTHING: set brands.status = 'active' ONLY when EVERY market is live - AND the brand_sites mirror succeeded. If any step fails, a pending brand - STAYS pending (stash + workspace pointer left intact for an idempotent retry). +4. the brand is NEVER downgraded — a partial failure returns 207 Multi-Status + while the brand stays active. ``` -All markets in one activate batch share the single resolved `brandDomain`, so -they collapse to one Site mirror; the all-or-nothing site-link guarantee is -therefore satisfied by that one mirror. (Distinct-domain markets under one brand -are not produced by this path today; if that changes, step 4 must require a +All markets in one activate batch share the single resolved `brandDomain`, so they +collapse to one Site mirror. (Distinct-domain markets under one brand are not +produced by this path today; if that changes, the site-link step must require a linked Site per distinct market domain.) -- Body: `{ brandDomain?, brandNames?, brandDisplayName?, markets?: [{ market, languageCode, name? }] }`. **All body fields are optional** (the schema no longer marks any required): a pending-draft activation can send an empty body and resolve everything from the stash (see below). `markets` is **capped at 50** (400 above that) — each market is a sequential upstream create+publish. -- **Deferred-provisioning fallback (pending/draft brands).** `markets` and `brandDomain` are optional in the body: a brand saved via the wizard's "Save as pending" path carries its intended market(s) and primary URL in `brands.pending_semrush_provisioning`. When the body omits them, activate falls back to the stash — `markets` come from `pendingSemrushProvisioning.markets`, and `brandDomain` is derived from `pendingSemrushProvisioning.primaryUrl` (hostname only, via the shared `hostnameFromUrlString`). The body always wins when both are present. Resolution rules after the fallback: - - **No URL/domain supplied at all** (neither body nor stash): the brand activates **sub-workspace-only** (HTTP 200, empty `markets[]`, no project) — see the sub-workspace-only shape above. This is NOT a 400. - - **A URL/domain was supplied but did not resolve to a hostname**: 400 (`brandDomain is required to provision a Semrush market`) — a typo must not silently strand the user with a project-less brand. - - **`markets` empty after resolution** (but `brandDomain` resolved): a single `US`/`en` fallback project is provisioned (matching the direct-create default). There is no longer a 400 for empty `markets`. -- **Stash lifecycle.** The deferred-provisioning stash is cleared (column set to `null`) **only on a fully-succeeded activation** (every market live AND the brand_sites mirror linked), saved atomically with the `active` flip. If any market fails — or every market is live but the site mirror fails — a pending brand STAYS pending and its stash (with the primaryUrl) is left intact for an idempotent retry; live markets return 409 `sliceExists` on the retry and the site-link + stash-clear re-run. -- Response: **200** when fully succeeded (pending brand flips to `active`). **502 `serenityActivationIncomplete`** when a *pending* brand's activation did not complete every step — any market failed, OR all markets are live but the `brand_sites` mirror failed; the brand stays `pending` and `markets[]` carries the per-market outcomes for a retry. **207 Multi-Status** applies ONLY to an *already-active* brand re-supplying markets where at least one fails — the brand is never downgraded and stays `active`. -- Idempotent: a market already live upstream returns 409 `sliceExists` and still counts as live, so a full re-activate of an already-live brand is a 200. +- Body: `{ brandDomain?, brandNames?, brandDisplayName?, markets?: [{ market, languageCode, name? }] }`. **All body fields are optional.** A pending brand's approve sends an empty body (→ sub-workspace-only). For an active brand, `markets` is **capped at 50** (400 above that); an empty `markets` with a resolved `brandDomain` provisions one `US`/`en` fallback project; a body that resolves no markets and no `brandDomain` is a no-op re-ensure. +- **No stash-driven provisioning (LLMO-6405).** A pending brand activates sub-workspace-only regardless of `brands.pending_semrush_provisioning`; the stash is no longer read to drive market provisioning (it is only *cleared* on that path). The column is slated for removal once existing drafts drain (serenity-docs post-GA cleanup). +- Response: **200** — a pending brand's sub-workspace-only activation (flips to `active`), or a fully-succeeded active reactivation. **502 `serenityActivationIncomplete`** — a pending brand whose sub-workspace ensured upstream but whose `active` flip did not persist (stays `pending`, idempotent retry). **207 Multi-Status** — an *already-active* brand re-supplying markets where ≥1 fails; never downgraded, stays `active`. +- Idempotent: a market already live upstream returns 409 `sliceExists` and still counts as live, so a full re-activate of an already-live active brand is a 200. `POST /serenity/deactivate` moves a brand back to flat mode: From d82c760644408609296bcafae6c159f91b26843e Mon Sep 17 00:00:00 2001 From: Igor Grubic Date: Wed, 22 Jul 2026 09:55:18 +0200 Subject: [PATCH 3/3] test(brands): fix pending-brand IT for org-flag create mode + add bare-create coverage (LLMO-6405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IT (test/it/shared/tests/brands.js): the LLMO-5870 unset+reuse test created bare brands and relied on the legacy no-anchor -> pending demotion. In a serenity-active org every create is now a Semrush create (a bare create provisions a sub-workspace and lands active), so create the two brands explicitly pending — the unset/reuse mechanic under test is unchanged. Fixes the it-postgres failure on #2878. - unit (test/controllers/brands.test.js): address the review coverage gaps — a bare sub-workspace provisioning failure surfaces and writes no brand row; ensureMarketSite is skipped on the bare path; a non-serenity org requesting generatePrompts:true (no market) is rejected 403. Retitle the pending-serenity-create test (it enters Semrush mode because the org is serenity-active, not because of the flag's presence). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/controllers/brands.test.js | 61 ++++++++++++++++++++++++++++++--- test/it/shared/tests/brands.js | 9 +++-- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/test/controllers/brands.test.js b/test/controllers/brands.test.js index 871f982cb8..3eca65bd45 100644 --- a/test/controllers/brands.test.js +++ b/test/controllers/brands.test.js @@ -5217,14 +5217,17 @@ describe('Brands Controller', () => { it('creates a bare sub-workspace (no project) when no market is supplied (serenity-active)', async () => { // LLMO-6405: market-scoped inputs moved to market creation, so a serenity-active // create with no market provisions just the sub-workspace (no project) — the - // market-based project provisioner is never called. + // market-based project provisioner is never called, and there is no market + // domain to mirror, so ensureMarketSite is skipped. const provisionStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-1' }); const bareStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-bare' }); const upsertStub = sinon.stub().resolves({ id: 'forced-id', name: 'New Brand' }); + const ensureMarketSiteStub = sinon.stub().resolves('site-x'); const controller = await buildController({ provisionBrandSubworkspace: provisionStub, provisionBrandSubworkspaceBare: bareStub, upsertBrand: upsertStub, + ensureMarketSite: ensureMarketSiteStub, }); const response = await controller.createBrandForOrg({ @@ -5238,12 +5241,39 @@ describe('Brands Controller', () => { expect(response.status).to.equal(201); expect(provisionStub.called).to.equal(false); expect(bareStub.called).to.equal(true); + // No market domain on the bare path → the Site mirror is skipped. + expect(ensureMarketSiteStub.called).to.equal(false); const upsertArgs = upsertStub.firstCall.args[0]; // Anchored by BOTH the bare sub-workspace and the primary site. expect(upsertArgs.semrushSubWorkspaceId).to.equal('ws-bare'); expect(upsertArgs.brand.baseSiteId).to.equal('site-123'); }); + it('surfaces a bare sub-workspace provisioning failure and does not write the brand', async () => { + // The bare sub-workspace is provisioned BEFORE the brand row is written; if + // that upstream call throws, the create surfaces the error and never persists + // a brand row (provisionedWorkspaceId stays null, so no compensation fires). + const bareStub = sinon.stub().rejects(new Error('Semrush provisioning failed')); + const upsertStub = sinon.stub().resolves({ id: 'x', name: 'New Brand' }); + const controller = await buildController({ + provisionBrandSubworkspace: sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-1' }), + provisionBrandSubworkspaceBare: bareStub, + upsertBrand: upsertStub, + }); + + const response = await controller.createBrandForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID }, + data: { name: 'New Brand', baseSiteId: 'site-123' }, + dataAccess: mockDataAccess, + attributes: { authInfo: { getType: () => 'ims', profile: { email: 'user@test.com' } } }, + }); + + expect(bareStub.called).to.equal(true); + expect(response.status).to.equal(500); + expect(upsertStub.called).to.equal(false); + }); + it('does a flat create (no Semrush provisioning) when the org is not serenity-active', async () => { const provisionStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-1' }); const bareStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-bare' }); @@ -5707,10 +5737,31 @@ describe('Brands Controller', () => { expect(upsertStub.called).to.equal(false); }); - it('STILL enters Semrush mode for a PENDING draft with generatePrompts:false and no market', async () => { - // The boolean-presence signal is preserved for drafts: a pending brand - // with generatePrompts:false and no market is a legitimate - // sub-workspace-only Semrush draft and must stash deferred provisioning. + it('403s a non-serenity org that requests generatePrompts:true (no market)', async () => { + // The guard rejects an ACTUAL provisioning request from a non-serenity org — + // a supplied market OR generatePrompts:true, not just a market. + const upsertStub = sinon.stub().resolves({ id: BRAND_UUID, name: 'X' }); + const controller = await buildCreateController({ + upsertBrand: upsertStub, + isSerenityActiveForOrg: sinon.stub().resolves(false), + }); + + const response = await controller.createBrandForOrg({ + ...context, + params: { spaceCatId: ORGANIZATION_ID }, + data: { name: 'X', generatePrompts: true }, + dataAccess: mockDataAccess, + attributes: { authInfo: { getType: () => 'ims', profile: { email: 'user@test.com' } } }, + }); + + expect(response.status).to.equal(403); + expect(upsertStub.called).to.equal(false); + }); + + it('stashes deferred provisioning for a PENDING serenity-active create (generatePrompts:false, no market)', async () => { + // In a serenity-active org a pending create is a Semrush create (the org flag + // decides mode, not the generatePrompts flag): it provisions nothing yet and + // stashes the deferred-provisioning blob for activation. const upsertStub = sinon.stub().resolves({ id: 'draft-id', name: 'Draft', status: 'pending' }); const provisionStub = sinon.stub().resolves({ semrushSubWorkspaceId: 'ws-x' }); const controller = await buildCreateController({ diff --git a/test/it/shared/tests/brands.js b/test/it/shared/tests/brands.js index 55b60753b7..e2a67d4ce1 100644 --- a/test/it/shared/tests/brands.js +++ b/test/it/shared/tests/brands.js @@ -186,9 +186,12 @@ export default function brandsTests(getHttpClient, resetData) { it('clears a pending brand baseSiteId so the freed site can be reused by another brand', async () => { const http = getHttpClient(); - // 1. A brand with no anchor (no baseSiteId / no Semrush) is created pending. + // 1. A pending brand with no baseSiteId. Created explicitly pending: in a + // serenity-active org every create is a Semrush create (LLMO-6405), so a + // bare active create would provision a sub-workspace and land active rather + // than falling back to the legacy no-anchor -> pending demotion. const createA = await http.admin.post(`/v2/orgs/${ORG_1_ID}/brands`, { - name: 'Pending URL Holder', region: ['US'], + name: 'Pending URL Holder', region: ['US'], status: 'pending', }); expect(createA.status).to.equal(201); expect(createA.body.status).to.equal('pending'); @@ -207,7 +210,7 @@ export default function brandsTests(getHttpClient, resetData) { // 3. A second pending brand cannot claim the same site while A holds it. const createB = await http.admin.post(`/v2/orgs/${ORG_1_ID}/brands`, { - name: 'Wants Same URL', region: ['US'], + name: 'Wants Same URL', region: ['US'], status: 'pending', }); expect(createB.status).to.equal(201); const brandBId = createB.body.id;