diff --git a/src/support/serenity/handlers/markets-subworkspace.js b/src/support/serenity/handlers/markets-subworkspace.js index 2b710efb10..10f903450b 100644 --- a/src/support/serenity/handlers/markets-subworkspace.js +++ b/src/support/serenity/handlers/markets-subworkspace.js @@ -449,7 +449,7 @@ export async function handleCreateMarketSubworkspace( } // Provision the dimension-root taxonomy on the project (independent of prompts), - // so classification can later apply intent/source/type values per prompt and the + // so classification can later apply intent/origin/type values per prompt and the // Categories surface has a `category` root to hang customer categories under. // Idempotent (resolve-before-create), and unconditional: every project carries // exactly the four dimension roots, whether or not it has prompts yet. @@ -828,7 +828,7 @@ export async function handleListTagsSubworkspace(transport, workspaceId, query, }), ]); // Merge by ID, not by name. Names are unique only per (project, parent), so a - // sub-category `human` and the `source` value `human` are two distinct tags — + // sub-category `human` and the `origin` value `human` are two distinct tags — // keying by name silently drops one of them. const byId = new Map(); // Both sources back-fill a missing upstream id with the tag's own name (a @@ -839,8 +839,8 @@ export async function handleListTagsSubworkspace(transport, workspaceId, query, // Placeholders are keyed by name, not by a synthetic composite: an id-less // entry carries ONLY its bare name (`id === name`), so two id-less tags sharing // a name are indistinguishable here — there is no id to tell a `category` value - // `human` from a `source` value `human` once both arrive without one. Keying by - // `(name, source)` would just emit two identical `{ id: name, name }` rows, a + // `human` from an `origin` value `human` once both arrive without one. Keying by + // `(name, dimension)` would just emit two identical `{ id: name, name }` rows, a // duplicate that is worse than the collapse. So they intentionally collapse to // one; the by-id merge above is what actually preserves two same-named tags, // and it fires whenever either carries a real upstream id (the common case). diff --git a/src/support/serenity/handlers/tags.js b/src/support/serenity/handlers/tags.js index 265233eff2..9c6d4d8859 100644 --- a/src/support/serenity/handlers/tags.js +++ b/src/support/serenity/handlers/tags.js @@ -37,13 +37,13 @@ import { republishBestEffort } from '../brand-urls.js'; * POST /serenity/tags — create a prompt TAG on a single market. * * Every tag is BARE-NAMED and lives under one of the four dimension roots - * (`category`, `intent`, `source`, `type`) on a market's project — the + * (`category`, `intent`, `origin`, `type`) on a market's project — the * `aio/tags` surface, via {@link createProjectTags}. A tag's dimension is its * root ancestor, never a prefix on its name, so `type` in the request body * names the dimension the value belongs to rather than something written into * the name. * - * The three CLOSED dimensions (`intent` / `source` / `type`) have a fixed value + * The three CLOSED dimensions (`intent` / `origin` / `type`) have a fixed value * enum: `name` must be one of those values, no `parentId` is accepted (their * values are always direct children of the dimension root), and the create is * resolve-or-create — a small, project-wide-shared set every caller may need diff --git a/src/support/serenity/prompt-tags.js b/src/support/serenity/prompt-tags.js index 5ce2138d2b..77324187ae 100644 --- a/src/support/serenity/prompt-tags.js +++ b/src/support/serenity/prompt-tags.js @@ -19,7 +19,7 @@ * serenity flow. * * A tag's DIMENSION is its root ancestor, not a prefix on its name. Every - * project's tag tree has exactly four roots — `category`, `intent`, `source`, + * project's tag tree has exactly four roots — `category`, `intent`, `origin`, * `type` — and every tag value is a bare-named descendant of one of them. No * tag name contains a `:`. A tag's dimension is therefore `path[0]` of the * upstream breadcrumb (verified against the live Semrush API: `path[]` is a @@ -30,8 +30,13 @@ * The upstream API caps neither, so nothing here does either. * * Names are NOT unique on their own — upstream uniqueness is scoped per - * `(project, parent)`. A sub-category named `human` and the `source` value + * `(project, parent)`. A sub-category named `human` and the `origin` value * `human` are two distinct tags. Never key a tag by name alone; key by id. + * + * The authorship root is being renamed `source` → `origin` in place across the + * live projects (origin-dimension.md). Until that migration's contract phase + * (WP-O6) lands, the tag-tree resolver tolerates BOTH names — see + * {@link LEGACY_AUTHORSHIP_ROOT_NAME} and `tag-tree.js`. */ /** @@ -40,20 +45,27 @@ export const DIMENSION = Object.freeze({ CATEGORY: 'category', INTENT: 'intent', - SOURCE: 'source', + ORIGIN: 'origin', TYPE: 'type', }); +/** + * The pre-rename name of the authorship root. Live projects provisioned before + * the rename still carry a root named `source` (with `ai` / `human` beneath it); + * the tolerant resolver accepts it in place of `origin` until WP-O6 drops this. + */ +export const LEGACY_AUTHORSHIP_ROOT_NAME = 'source'; + /** Root names, in the order they are provisioned on a project. */ export const DIMENSION_ROOT_NAMES = Object.freeze([ DIMENSION.CATEGORY, DIMENSION.INTENT, - DIMENSION.SOURCE, + DIMENSION.ORIGIN, DIMENSION.TYPE, ]); -/** `source` values — who authored the prompt. */ -export const SOURCE_VALUE = Object.freeze({ +/** `origin` values — who authored the prompt. */ +export const ORIGIN_VALUE = Object.freeze({ AI: 'ai', HUMAN: 'human', }); @@ -98,14 +110,14 @@ export const TYPE_VALUE = Object.freeze({ */ export const CLOSED_DIMENSION_VALUES = Object.freeze({ [DIMENSION.INTENT]: Object.freeze(Object.values(INTENT_VALUE)), - [DIMENSION.SOURCE]: Object.freeze(Object.values(SOURCE_VALUE)), + [DIMENSION.ORIGIN]: Object.freeze(Object.values(ORIGIN_VALUE)), [DIMENSION.TYPE]: Object.freeze(Object.values(TYPE_VALUE)), }); /** The closed dimensions — fixed vocabularies, never customer-authored. */ export const CLOSED_DIMENSIONS = Object.freeze([ DIMENSION.INTENT, - DIMENSION.SOURCE, + DIMENSION.ORIGIN, DIMENSION.TYPE, ]); @@ -120,30 +132,37 @@ export const OPEN_DIMENSIONS = Object.freeze([DIMENSION.CATEGORY]); export const ALL_DIMENSIONS = Object.freeze([...OPEN_DIMENSIONS, ...CLOSED_DIMENSIONS]); /** - * The closed-dimension values applied to EVERY AI-generated prompt: `source:ai` - * (AI-authored) plus the default `Informational` intent (the most common intent - * for brand-topic prompts; re-classification can refine it later). The `type` - * value is classified per prompt at generation time (branded vs non-branded — - * see the handler), so it is NOT seeded here. + * The closed-dimension values applied to EVERY AI-generated prompt: the `origin` + * value `ai` (AI-authored) plus the default `Informational` intent (the most + * common intent for brand-topic prompts; re-classification can refine it later). + * The `type` value is classified per prompt at generation time (branded vs + * non-branded — see the handler), so it is NOT seeded here. * * Each entry names a dimension and the bare value beneath it; the caller resolves * the pair to an upstream tag id against the project's tree. */ export const STANDARD_PROMPT_TAG_VALUES = Object.freeze([ - Object.freeze({ dimension: DIMENSION.SOURCE, name: SOURCE_VALUE.AI }), + Object.freeze({ dimension: DIMENSION.ORIGIN, name: ORIGIN_VALUE.AI }), Object.freeze({ dimension: DIMENSION.INTENT, name: INTENT_VALUE.INFORMATIONAL }), ]); /** - * True when `name` is one of the four dimension roots. Root names are reserved: + * True when `name` is a reserved dimension-root name. Root names are reserved: * a customer category may not be called `category`, and a closed value may not * be minted at the root level. * + * While the `source` → `origin` rename is in flight, the legacy authorship name + * ({@link LEGACY_AUTHORSHIP_ROOT_NAME}) is ALSO reserved — a customer must not be + * able to mint a tag named `source` during the migration window, or it could be + * mistaken for (or collide with) the legacy authorship root the tolerant resolver + * still adopts. Dropped with the rest of the fallback at WP-O6. + * * @param {string} name - a bare tag name. * @returns {boolean} */ export function isDimensionRootName(name) { - return (/** @type {readonly string[]} */ (DIMENSION_ROOT_NAMES)).includes(name); + return name === LEGACY_AUTHORSHIP_ROOT_NAME + || (/** @type {readonly string[]} */ (DIMENSION_ROOT_NAMES)).includes(name); } /** diff --git a/src/support/serenity/tag-tree.js b/src/support/serenity/tag-tree.js index 825d41db88..9712cb8a47 100644 --- a/src/support/serenity/tag-tree.js +++ b/src/support/serenity/tag-tree.js @@ -26,7 +26,7 @@ * Every create in this module is therefore resolve-before-create. Names are * unique per `(project, parent)`, not per project, so the resolve must be * scoped to the parent — a bare-name lookup across the whole tree would - * conflate a sub-category `human` with the `source` value `human`. + * conflate a sub-category `human` with the `origin` value `human`. * - Tag writes land in the project's DRAFT layer, and a default read serves the * LIVE view. Reads here go through {@link listProjectTagTree}, which passes * `draft: true`, so a tag this module just created is visible to the tag @@ -51,6 +51,8 @@ import { DIMENSION_ROOT_NAMES, CLOSED_DIMENSION_VALUES, CLOSED_DIMENSIONS, + ORIGIN_VALUE, + LEGACY_AUTHORSHIP_ROOT_NAME, } from './prompt-tags.js'; /** @@ -128,6 +130,9 @@ export async function indexLevelByName(transport, semrushWorkspaceId, projectId, * @param {string} parentId - '' to create at the root level. * @param {readonly string[]} wanted - bare names that must exist under `parentId`. * @param {object} [log] - logger. + * @param {Map} [preRead] - an already-read `indexLevelByName` of this + * parent's level, reused instead of reading it again. Lets a caller that has already + * inspected the level (e.g. the tolerant root resolver) avoid a redundant read. * @returns {Promise<{ byName: Map, createdNames: string[] }>} * `byName` maps every wanted name to its tag id. */ @@ -138,8 +143,10 @@ export async function ensureChildren( parentId, wanted, log, + preRead, ) { - const existing = await indexLevelByName(transport, semrushWorkspaceId, projectId, parentId, log); + const existing = preRead + ?? await indexLevelByName(transport, semrushWorkspaceId, projectId, parentId, log); const missing = wanted.filter((name) => !existing.has(name)); if (missing.length === 0) { return { byName: existing, createdNames: [] }; @@ -210,27 +217,94 @@ export async function ensureChildren( return { byName, createdNames: missing.filter((name) => existing.has(name)) }; } +/** + * True when a root tag's children are a subset of the authorship vocabulary + * `{ai, human}`. A childless root passes vacuously (a not-yet-populated authorship + * root); a `source` root carrying producing-system values (`config`, `gsc`, …) does + * NOT — that is the companion `source` dimension (source-dimension.md §9), not + * authorship. This guard is what lets the two names coexist safely during the rename. + * + * @param {object} transport + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @param {string} rootId + * @param {object} [log] + * @returns {Promise} + */ +async function childrenAreAuthorship(transport, semrushWorkspaceId, projectId, rootId, log) { + const children = await indexLevelByName(transport, semrushWorkspaceId, projectId, rootId, log); + const authorship = new Set(/** @type {readonly string[]} */ (Object.values(ORIGIN_VALUE))); + return [...children.keys()].every((name) => authorship.has(name)); +} + /** * Resolves the four dimension roots, creating any that a project is missing. * Older projects predate this taxonomy entirely, so this is the seam that brings * them forward on first touch. * + * The authorship root is resolved TOLERANTLY while the `source` → `origin` rename is + * in flight (origin-dimension.md): an existing `origin` root, OR a legacy `source` + * root whose children are a subset of `{ai, human}` ({@link childrenAreAuthorship} — + * the guard that keeps it from adopting the companion producing-system `source` + * dimension), satisfies the authorship dimension in place. `origin` is created ONLY + * when neither exists — a blind create would mint an empty SECOND authorship root the + * moment code and data disagree (origin-dimension.md §8). Either way the returned + * map's `origin` key maps to whichever physical root was resolved, so callers key on + * `DIMENSION.ORIGIN` regardless. Removed with the fallback by WP-O6. + * * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {string} projectId * @param {object} [log] - logger. - * @returns {Promise>} root name → tag id, for all four roots. + * @returns {Promise>} root name → tag id, in root order, with the + * `origin` key carrying the resolved authorship root's id. */ export async function ensureDimensionRoots(transport, semrushWorkspaceId, projectId, log) { + const existing = await indexLevelByName(transport, semrushWorkspaceId, projectId, '', log); + + // Tolerant authorship resolution: prefer `origin`; else adopt a legacy `source` root + // in place (guarded so the companion producing-system `source` dimension is never + // mistaken for authorship). + let authorshipId = existing.get(DIMENSION.ORIGIN); + if (!authorshipId) { + const legacyId = existing.get(LEGACY_AUTHORSHIP_ROOT_NAME); + if (legacyId + && await childrenAreAuthorship(transport, semrushWorkspaceId, projectId, legacyId, log)) { + log?.info?.('ensureDimensionRoots: adopting the legacy `source` authorship root in place', { + semrushWorkspaceId, projectId, rootId: legacyId, + }); + authorshipId = legacyId; + } + } + + // Resolve-or-create every root except `origin`, and `origin` too UNLESS an authorship + // root was already found — creating it only then keeps the fresh-project path a single + // create call while never minting a second authorship root on a mid-rename project. + const wanted = DIMENSION_ROOT_NAMES.filter( + (name) => name !== DIMENSION.ORIGIN || !authorshipId, + ); + // Reuse the root-level read above — the tolerant resolve costs no extra read on the + // common path (only `childrenAreAuthorship` adds one, and only when a legacy `source` + // root is present). const { byName } = await ensureChildren( transport, semrushWorkspaceId, projectId, '', - DIMENSION_ROOT_NAMES, + wanted, log, + existing, ); - return byName; + + // Return the roots in canonical order, with `origin` carrying the resolved id. + const roots = new Map(); + for (const name of DIMENSION_ROOT_NAMES) { + roots.set( + name, + name === DIMENSION.ORIGIN ? (authorshipId ?? byName.get(name)) : byName.get(name), + ); + } + return roots; } /** @@ -465,7 +539,7 @@ export async function provisionDimensionTree(transport, semrushWorkspaceId, proj * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {string} projectId - * @param {string} dimension - a closed dimension (`intent` / `source` / `type`). + * @param {string} dimension - a closed dimension (`intent` / `origin` / `type`). * @param {string} value - a bare value from that dimension's fixed vocabulary. * @param {object} [log] - logger. * @returns {Promise<{ id: string, rootId: string, created: boolean }>} `created` diff --git a/test/it/shared/tests/serenity.js b/test/it/shared/tests/serenity.js index ea892efc21..3c7712ab5e 100644 --- a/test/it/shared/tests/serenity.js +++ b/test/it/shared/tests/serenity.js @@ -320,7 +320,7 @@ export default function serenityTests( ); expect(roots.status).to.equal(200); expect(roots.body.items.map((t) => t.name)) - .to.have.members(['category', 'intent', 'source', 'type']); + .to.have.members(['category', 'intent', 'origin', 'type']); const categoryRoot = roots.body.items.find((t) => t.name === 'category'); expect(res.body.parentId).to.equal(categoryRoot.id); }); @@ -475,20 +475,20 @@ export default function serenityTests( expect(res.status).to.equal(400); }); - it('POST /serenity/tags resolves a closed-dimension tag idempotently (source/intent/type)', async () => { + it('POST /serenity/tags resolves a closed-dimension tag idempotently (origin/intent/type)', async () => { await createUsMarket(); const first = await getHttpClient().admin.post(`${base}/tags`, { - type: 'source', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', }); expect(first.status).to.equal(200); - expect(first.body).to.include({ type: 'source', name: 'ai' }); + expect(first.body).to.include({ type: 'origin', name: 'ai' }); expect(first.body.id).to.be.a('string').that.is.not.empty; - // The value hangs under the `source` root, never at the root level. + // The value hangs under the `origin` root, never at the root level. expect(first.body.parentId).to.be.a('string').that.is.not.empty; // Same closed-dimension value again — resolved, not re-created (no upstream collision). const second = await getHttpClient().admin.post(`${base}/tags`, { - type: 'source', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', }); expect(second.status).to.equal(200); expect(second.body).to.include({ name: 'ai', id: first.body.id, created: false }); @@ -500,7 +500,7 @@ export default function serenityTests( it('PATCH /serenity/tags/:tagId 400s a rename of a closed-dimension value', async () => { await createUsMarket(); const created = await getHttpClient().admin.post(`${base}/tags`, { - type: 'source', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', }); expect(created.status).to.equal(200); const res = await getHttpClient().admin.patch(`${base}/tags/${created.body.id}`, { diff --git a/test/support/serenity/fixtures/tag-tree.js b/test/support/serenity/fixtures/tag-tree.js index 5adcfe7ef2..7d15eb44d6 100644 --- a/test/support/serenity/fixtures/tag-tree.js +++ b/test/support/serenity/fixtures/tag-tree.js @@ -28,7 +28,7 @@ import { * does not exist yields an empty page, exactly as upstream does. * * The fixture deliberately carries a cross-dimension name collision: the - * sub-category `human` under `Running Shoes` and the `source` value `human` are + * sub-category `human` under `Running Shoes` and the `origin` value `human` are * different tags with the same bare name. Any handler that keys tags by name * rather than id collapses them, and the tests that use this tree catch it. */ @@ -36,7 +36,7 @@ import { export const TAG_IDS = Object.freeze({ categoryRoot: 'root-category', intentRoot: 'root-intent', - sourceRoot: 'root-source', + originRoot: 'root-origin', typeRoot: 'root-type', intentInformational: 'intent-informational', @@ -45,8 +45,8 @@ export const TAG_IDS = Object.freeze({ intentTransactional: 'intent-transactional', intentNavigational: 'intent-navigational', - sourceAi: 'source-ai', - sourceHuman: 'source-human', + originAi: 'origin-ai', + originHuman: 'origin-human', typeBranded: 'type-branded', typeNonBranded: 'type-non-branded', @@ -58,7 +58,7 @@ export const TAG_IDS = Object.freeze({ const ROOT_IDS = Object.freeze({ category: TAG_IDS.categoryRoot, intent: TAG_IDS.intentRoot, - source: TAG_IDS.sourceRoot, + origin: TAG_IDS.originRoot, type: TAG_IDS.typeRoot, }); @@ -70,7 +70,7 @@ const CLOSED_VALUE_IDS = Object.freeze({ Transactional: TAG_IDS.intentTransactional, Navigational: TAG_IDS.intentNavigational, }, - source: { ai: TAG_IDS.sourceAi, human: TAG_IDS.sourceHuman }, + origin: { ai: TAG_IDS.originAi, human: TAG_IDS.originHuman }, type: { branded: TAG_IDS.typeBranded, 'non-branded': TAG_IDS.typeNonBranded }, }); @@ -104,7 +104,7 @@ export function dimensionTreeLevels(extraLevels = {}) { })); const closedLevels = {}; - for (const dimension of ['intent', 'source', 'type']) { + for (const dimension of ['intent', 'origin', 'type']) { closedLevels[ROOT_IDS[dimension]] = CLOSED_DIMENSION_VALUES[dimension].map((value) => ( upstreamTag({ id: CLOSED_VALUE_IDS[dimension][value], @@ -125,7 +125,7 @@ export function dimensionTreeLevels(extraLevels = {}) { childrenCount: 1, path: CATEGORY_CRUMB, })], - // Depth 3. Shares its bare name with the `source` value `human`. + // Depth 3. Shares its bare name with the `origin` value `human`. [TAG_IDS.categoryRunningShoes]: [upstreamTag({ id: TAG_IDS.subCategoryHuman, name: 'human', diff --git a/test/support/serenity/handlers/markets-subworkspace.test.js b/test/support/serenity/handlers/markets-subworkspace.test.js index 9a8a9149a7..a6bf85ba35 100644 --- a/test/support/serenity/handlers/markets-subworkspace.test.js +++ b/test/support/serenity/handlers/markets-subworkspace.test.js @@ -35,7 +35,7 @@ use(sinonChai); // Every generated prompt carries the two standard values (source=ai, // intent=Informational); the third tag is the per-prompt computed `type`. -const STANDARD_IDS = [TAG_IDS.sourceAi, TAG_IDS.intentInformational]; +const STANDARD_IDS = [TAG_IDS.originAi, TAG_IDS.intentInformational]; const BRAND = 'brand-1'; const WS = 'subworkspace-ws-1'; diff --git a/test/support/serenity/handlers/prompts.test.js b/test/support/serenity/handlers/prompts.test.js index 5035fb99c5..8a21d6003a 100644 --- a/test/support/serenity/handlers/prompts.test.js +++ b/test/support/serenity/handlers/prompts.test.js @@ -278,11 +278,11 @@ describe('handlers/prompts.js — handleListPrompts', () => { ], }, { - id: TAG_IDS.sourceHuman, + id: TAG_IDS.originHuman, name: 'human', children_count: 0, - parent_id: TAG_IDS.sourceRoot, - path: [{ id: TAG_IDS.sourceRoot, name: 'source' }], + parent_id: TAG_IDS.originRoot, + path: [{ id: TAG_IDS.originRoot, name: 'origin' }], }, ], }], @@ -296,9 +296,9 @@ describe('handlers/prompts.js — handleListPrompts', () => { const { tags } = result.items[0]; expect(tags).to.have.lengthOf(2); - expect(tags.map((t) => t.path[0].name)).to.deep.equal(['category', 'source']); + expect(tags.map((t) => t.path[0].name)).to.deep.equal(['category', 'origin']); expect(tags[0].parentId).to.equal(TAG_IDS.categoryRunningShoes); - expect(tags[1].parentId).to.equal(TAG_IDS.sourceRoot); + expect(tags[1].parentId).to.equal(TAG_IDS.originRoot); // The deprecated name-keyed view collapses them; only one id survives. expect(Object.keys(result.items[0].tagMap)).to.deep.equal(['human']); // Listing prompts costs exactly ONE upstream call — no tag-tree walk. @@ -1926,7 +1926,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) // The four roots are created at the root level, then `branded` beneath // the freshly-minted `type` root. expect(createProjectTags.firstCall.args[2]).to.deep.equal([ - 'category', 'intent', 'source', 'type', + 'category', 'intent', 'origin', 'type', ]); expect(createProjectTags.firstCall.args[3]).to.deep.equal({}); expect(createProjectTags.secondCall.args[2]).to.deep.equal(['branded']); diff --git a/test/support/serenity/handlers/tags.test.js b/test/support/serenity/handlers/tags.test.js index 0e5a2e68d5..e19894c9ed 100644 --- a/test/support/serenity/handlers/tags.test.js +++ b/test/support/serenity/handlers/tags.test.js @@ -146,7 +146,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { createProjectTags.onFirstCall().resolves([ { id: 'r-category', name: 'category' }, { id: 'r-intent', name: 'intent' }, - { id: 'r-source', name: 'source' }, + { id: 'r-origin', name: 'origin' }, { id: 'r-type', name: 'type' }, ]); createProjectTags.onSecondCall().resolves([ @@ -165,7 +165,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { ); expect(res.status).to.equal(201); - expect(createProjectTags.firstCall.args[2]).to.deep.equal(['category', 'intent', 'source', 'type']); + expect(createProjectTags.firstCall.args[2]).to.deep.equal(['category', 'intent', 'origin', 'type']); expect(createProjectTags.secondCall.args[2]).to.deep.equal(['Footwear']); expect(createProjectTags.secondCall.args[3]).to.deep.equal({ parentId: 'r-category' }); expect(res.body).to.include({ id: 'new-cat', parentId: 'r-category' }); @@ -193,7 +193,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { // The roots were attempted; the category itself never was. expect(transport.createProjectTags).to.have.been.calledOnce; expect(transport.createProjectTags.firstCall.args[2]) - .to.deep.equal(['category', 'intent', 'source', 'type']); + .to.deep.equal(['category', 'intent', 'origin', 'type']); }); it('502s when the upstream create response carries no usable id', async () => { @@ -231,7 +231,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { it('400s a name that shadows a reserved dimension root', async () => { const transport = makeTransport(); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); - for (const name of ['category', 'intent', 'source', 'type']) { + for (const name of ['category', 'intent', 'origin', 'type']) { // eslint-disable-next-line no-await-in-loop await expect(handler.handleCreateTag( transport, @@ -245,6 +245,27 @@ describe('serenity tags handler (POST /serenity/tags)', () => { expect(transport.createProjectTags).to.not.have.been.called; }); + it('400s the legacy `source` name too while the origin rename is in flight', async () => { + // Migration-window guard: until WP-O6, `source` is reserved alongside the four + // roots so a customer cannot mint a tag that collides with the legacy authorship + // root the tolerant resolver still adopts. + const transport = makeTransport(); + const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); + await expect(handler.handleCreateTag( + transport, + dataAccess, + BRAND, + WORKSPACE, + { ...validBody, name: 'source' }, + fakeLog(), + )).to.be.rejected.then((err) => { + expect(err.status).to.equal(400); + // The domain guard, not a generic schema rejection. + expect(err.message).to.match(/reserved dimension root name/); + }); + expect(transport.createProjectTags).to.not.have.been.called; + }); + it('404s (marketNotFound) when no project backs the slice', async () => { const transport = makeTransport(); const dataAccess = makeDataAccess(null); @@ -321,11 +342,11 @@ describe('serenity tags handler (POST /serenity/tags)', () => { it('creates a closed-dimension value under its root when absent (200, created:true)', async () => { // The `source` root exists but is empty, so `ai` must be minted beneath it. const levels = dimensionTreeLevels(); - levels[TAG_IDS.sourceRoot] = []; + levels[TAG_IDS.originRoot] = []; const transport = makeTransport({ listProjectTags: makeListProjectTagsStub(levels), createProjectTags: sinon.stub().resolves([ - { id: 'tag-source-ai', name: 'ai', parent_id: TAG_IDS.sourceRoot }, + { id: 'tag-source-ai', name: 'ai', parent_id: TAG_IDS.originRoot }, ]), }); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); @@ -335,17 +356,17 @@ describe('serenity tags handler (POST /serenity/tags)', () => { BRAND, WORKSPACE, { - type: 'source', name: 'ai', geoTargetId: 2840, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: 2840, languageCode: 'en', }, fakeLog(), ); expect(res.status).to.equal(200); expect(res.body).to.include({ - type: 'source', name: 'ai', id: 'tag-source-ai', parentId: TAG_IDS.sourceRoot, created: true, + type: 'origin', name: 'ai', id: 'tag-source-ai', parentId: TAG_IDS.originRoot, created: true, }); // A closed value is a CHILD of its dimension root, never a root itself. expect(transport.createProjectTags) - .to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-1', ['ai'], { parentId: TAG_IDS.sourceRoot }); + .to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-1', ['ai'], { parentId: TAG_IDS.originRoot }); }); it('resolves an EXISTING closed-dimension value without creating a duplicate (200, created:false)', async () => { @@ -415,7 +436,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { BRAND, WORKSPACE, { - type: 'source', name: 'ai', geoTargetId: 2840, languageCode: 'en', parentId: 'root-1', + type: 'origin', name: 'ai', geoTargetId: 2840, languageCode: 'en', parentId: 'root-1', }, fakeLog(), )).to.be.rejected.then((err) => expect(err.status).to.equal(400)); @@ -560,24 +581,24 @@ describe('serenity tags handler (POST /serenity/tags)', () => { // The `source` root exists but is empty, so `ai` must be minted beneath it, // and a newly minted value must be published so it is live rather than draft. const levels = dimensionTreeLevels(); - levels[TAG_IDS.sourceRoot] = []; + levels[TAG_IDS.originRoot] = []; const transport = makeTransport({ listProjectTags: makeListProjectTagsStub(levels), createProjectTags: sinon.stub().resolves([ - { id: 'tag-source-ai', name: 'ai', parent_id: TAG_IDS.sourceRoot }, + { id: 'tag-source-ai', name: 'ai', parent_id: TAG_IDS.originRoot }, ]), }); const res = await handler.handleCreateTagSubworkspace( transport, WORKSPACE, { - type: 'source', name: 'ai', geoTargetId: 2840, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: 2840, languageCode: 'en', }, fakeLog(), ); expect(res.status).to.equal(200); expect(res.body).to.include({ - type: 'source', name: 'ai', id: 'tag-source-ai', parentId: TAG_IDS.sourceRoot, created: true, + type: 'origin', name: 'ai', id: 'tag-source-ai', parentId: TAG_IDS.originRoot, created: true, }); expect(transport.publishProject).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-sub-1'); }); @@ -728,7 +749,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { dataAccess, BRAND, WORKSPACE, - { ...validBody, parentId: TAG_IDS.sourceHuman }, + { ...validBody, parentId: TAG_IDS.originHuman }, fakeLog(), ).then(() => null, (e) => e); @@ -879,7 +900,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { it('400s on a rename to a reserved dimension root name', async () => { const transport = makeTransport(); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); - for (const name of ['category', 'intent', 'source', 'type']) { + for (const name of ['category', 'intent', 'origin', 'type']) { // eslint-disable-next-line no-await-in-loop await expect(handler.handleUpdateTag( transport, @@ -894,6 +915,26 @@ describe('serenity tags handler (POST /serenity/tags)', () => { expect(transport.updateProjectTag).to.not.have.been.called; }); + it('400s on a rename to the legacy `source` name during the migration window', async () => { + // Mirrors the create-path guard: `source` is reserved until WP-O6, so a tag + // cannot be renamed onto it any more than a fresh one can be minted with it. + const transport = makeTransport(); + const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); + await expect(handler.handleUpdateTag( + transport, + dataAccess, + BRAND, + WORKSPACE, + TARGET, + { ...updateBody, name: 'source' }, + fakeLog(), + )).to.be.rejected.then((err) => { + expect(err.status).to.equal(400); + expect(err.message).to.match(/reserved dimension root name/); + }); + expect(transport.updateProjectTag).to.not.have.been.called; + }); + it('400s on a name that is too long or carries control characters', async () => { const transport = makeTransport(); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); @@ -1133,13 +1174,13 @@ describe('serenity tags handler (POST /serenity/tags)', () => { dataAccess, BRAND, WORKSPACE, - TAG_IDS.sourceHuman, + TAG_IDS.originHuman, { name: 'manual', geoTargetId: 2840, languageCode: 'en' }, fakeLog(), ).then(() => null, (e) => e); expect(err.status).to.equal(400); - expect(err.message).to.match(/closed "source" dimension cannot be renamed or re-parented/); + expect(err.message).to.match(/closed "origin" dimension cannot be renamed or re-parented/); expect(transport.updateProjectTag).to.not.have.been.called; }); @@ -1154,7 +1195,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { TAG_IDS.categoryRunningShoes, { name: 'Running Shoes', - parentId: TAG_IDS.sourceRoot, + parentId: TAG_IDS.originRoot, geoTargetId: 2840, languageCode: 'en', }, diff --git a/test/support/serenity/prompt-tags.test.js b/test/support/serenity/prompt-tags.test.js index ed0b188a36..75665ffeed 100644 --- a/test/support/serenity/prompt-tags.test.js +++ b/test/support/serenity/prompt-tags.test.js @@ -15,7 +15,7 @@ import { expect } from 'chai'; import { DIMENSION, DIMENSION_ROOT_NAMES, - SOURCE_VALUE, + ORIGIN_VALUE, INTENT_VALUE, TYPE_VALUE, CLOSED_DIMENSION_VALUES, @@ -31,13 +31,13 @@ import { describe('serenity prompt-tags taxonomy', () => { describe('dimension roots', () => { it('has exactly four roots, all bare-named', () => { - expect([...DIMENSION_ROOT_NAMES]).to.deep.equal(['category', 'intent', 'source', 'type']); + expect([...DIMENSION_ROOT_NAMES]).to.deep.equal(['category', 'intent', 'origin', 'type']); DIMENSION_ROOT_NAMES.forEach((n) => expect(n).to.not.include(':')); }); it('splits the roots into one open and three closed dimensions', () => { expect([...OPEN_DIMENSIONS]).to.deep.equal([DIMENSION.CATEGORY]); - expect([...CLOSED_DIMENSIONS]).to.deep.equal(['intent', 'source', 'type']); + expect([...CLOSED_DIMENSIONS]).to.deep.equal(['intent', 'origin', 'type']); expect([...ALL_DIMENSIONS].sort()).to.deep.equal([...DIMENSION_ROOT_NAMES].sort()); }); @@ -63,9 +63,9 @@ describe('serenity prompt-tags taxonomy', () => { }); it('carries the source and type vocabularies', () => { - expect([...closedValuesOf(DIMENSION.SOURCE)]).to.deep.equal(['ai', 'human']); + expect([...closedValuesOf(DIMENSION.ORIGIN)]).to.deep.equal(['ai', 'human']); expect([...closedValuesOf(DIMENSION.TYPE)]).to.deep.equal(['branded', 'non-branded']); - expect(SOURCE_VALUE.AI).to.equal('ai'); + expect(ORIGIN_VALUE.AI).to.equal('ai'); expect(TYPE_VALUE.NON_BRANDED).to.equal('non-branded'); }); @@ -88,7 +88,7 @@ describe('serenity prompt-tags taxonomy', () => { describe('STANDARD_PROMPT_TAG_VALUES', () => { it('seeds source=ai + intent=Informational only (type is classified per prompt)', () => { expect(STANDARD_PROMPT_TAG_VALUES.map((t) => [t.dimension, t.name])).to.deep.equal([ - ['source', 'ai'], + ['origin', 'ai'], ['intent', 'Informational'], ]); }); diff --git a/test/support/serenity/tag-tree.test.js b/test/support/serenity/tag-tree.test.js index 6d69f48555..b39ed26e04 100644 --- a/test/support/serenity/tag-tree.test.js +++ b/test/support/serenity/tag-tree.test.js @@ -260,7 +260,7 @@ describe('serenity tag-tree', () => { createProjectTags: sinon.stub(), }; const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); - expect([...roots.keys()]).to.deep.equal(['category', 'intent', 'source', 'type']); + expect([...roots.keys()]).to.deep.equal(['category', 'intent', 'origin', 'type']); expect(transport.createProjectTags).to.not.have.been.called; }); @@ -270,9 +270,109 @@ describe('serenity tag-tree', () => { const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); expect(createProjectTags).to.have.been.calledOnce; expect(createProjectTags.firstCall.args[2]) - .to.deep.equal(['category', 'intent', 'source', 'type']); + .to.deep.equal(['category', 'intent', 'origin', 'type']); expect(roots.get('type')).to.equal('created::type'); }); + + it('adopts a legacy `source` authorship root in place, minting no second `origin`', async () => { + // A project the reshape has not yet renamed: its authorship root is still `source`, + // with `ai`/`human` beneath it (origin-dimension.md §7 gate 3). + const legacyLevels = { + '': [ + { id: 'root-category', name: 'category', children_count: 0 }, + { id: 'root-intent', name: 'intent', children_count: 5 }, + { id: 'root-source', name: 'source', children_count: 2 }, + { id: 'root-type', name: 'type', children_count: 2 }, + ], + 'root-source': [ + { id: 'legacy-ai', name: 'ai', parent_id: 'root-source' }, + { id: 'legacy-human', name: 'human', parent_id: 'root-source' }, + ], + }; + const transport = { + listProjectTags: makeListProjectTagsStub(legacyLevels), + createProjectTags: sinon.stub(), + }; + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + // The `origin` key maps to the physical `source` root — adopted in place … + expect(roots.get('origin')).to.equal('root-source'); + // … and NOTHING was created: no empty second authorship root (spec §8). + expect(transport.createProjectTags).to.not.have.been.called; + }); + + it('does NOT adopt a `source` root carrying non-authorship values; creates `origin`', async () => { + // The companion producing-system `source` dimension (source-dimension.md §9): its + // children are not {ai, human}, so it must not be mistaken for authorship. + const created = []; + const levels = { + '': [ + { id: 'root-category', name: 'category', children_count: 0 }, + { id: 'root-intent', name: 'intent', children_count: 5 }, + { id: 'root-source', name: 'source', children_count: 1 }, + { id: 'root-type', name: 'type', children_count: 2 }, + ], + 'root-source': [{ id: 'src-config', name: 'config', parent_id: 'root-source' }], + }; + const transport = { + listProjectTags: makeListProjectTagsStub(levels), + createProjectTags: sinon.stub().callsFake((ws, pid, names) => { + created.push(...names); + return Promise.resolve(names.map((n) => ({ id: `made-${n}`, name: n }))); + }), + }; + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + expect(created).to.deep.equal(['origin']); + expect(roots.get('origin')).to.equal('made-origin'); + }); + + it('prefers `origin` over a legacy `source` when a project carries BOTH roots', async () => { + // A partially-migrated project: the new `origin` root already exists alongside + // the not-yet-cleaned-up legacy `source` root. `origin` must win, and the + // orphan `source` is left untouched (removed at WP-O6) — never a second mint. + const bothLevels = { + '': [ + { id: 'root-category', name: 'category', children_count: 0 }, + { id: 'root-intent', name: 'intent', children_count: 5 }, + { id: 'root-origin', name: 'origin', children_count: 2 }, + { id: 'root-source', name: 'source', children_count: 2 }, + { id: 'root-type', name: 'type', children_count: 2 }, + ], + }; + const transport = { + listProjectTags: makeListProjectTagsStub(bothLevels), + createProjectTags: sinon.stub(), + }; + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + // The `origin` key resolves to the real `origin` root, not the legacy `source`. + expect(roots.get('origin')).to.equal('root-origin'); + // Nothing was created and the orphan `source` was not touched. + expect(transport.createProjectTags).to.not.have.been.called; + // Performance contract: finding `origin` short-circuits before any legacy + // adoption, so no child-level read of the orphan `source` — exactly ONE read. + expect(transport.listProjectTags).to.have.callCount(1); + }); + + it('adopts a CHILDLESS legacy `source` root in place (vacuous authorship guard)', async () => { + // The intentional vacuous-true branch of `childrenAreAuthorship`: a `source` + // root not yet populated is still an authorship root, so it is adopted rather + // than shadowed by a second `origin`. Documents the behavior until WP-O6. + const childlessLevels = { + '': [ + { id: 'root-category', name: 'category', children_count: 0 }, + { id: 'root-intent', name: 'intent', children_count: 5 }, + { id: 'root-source', name: 'source', children_count: 0 }, + { id: 'root-type', name: 'type', children_count: 2 }, + ], + 'root-source': [], + }; + const transport = { + listProjectTags: makeListProjectTagsStub(childlessLevels), + createProjectTags: sinon.stub(), + }; + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + expect(roots.get('origin')).to.equal('root-source'); + expect(transport.createProjectTags).to.not.have.been.called; + }); }); describe('provisionDimensionTree', () => { @@ -284,7 +384,7 @@ describe('serenity tag-tree', () => { const { roots, values } = await provisionDimensionTree(transport, WS, PROJECT, fakeLog()); expect(roots.get('category')).to.equal(TAG_IDS.categoryRoot); - expect([...values.keys()]).to.deep.equal(['intent', 'source', 'type']); + expect([...values.keys()]).to.deep.equal(['intent', 'origin', 'type']); // The open dimension's children are customer content, never provisioned. expect(values.has('category')).to.equal(false); expect([...values.get('intent').keys()]).to.deep.equal([ @@ -300,7 +400,7 @@ describe('serenity tag-tree', () => { const { values } = await provisionDimensionTree(transport, WS, PROJECT, fakeLog()); // Roots first, then one call per closed dimension. expect(createProjectTags).to.have.callCount(4); - expect(values.get('source').get('ai')).to.equal('created:created::source:ai'); + expect(values.get('origin').get('ai')).to.equal('created:created::origin:ai'); expect(values.get('intent').get('Navigational')) .to.equal('created:created::intent:Navigational'); }); @@ -337,8 +437,8 @@ describe('serenity tag-tree', () => { const listProjectTags = makeListProjectTagsStub(); const transport = { listProjectTags, createProjectTags: sinon.stub() }; const { roots, values } = await provisionDimensionTree(transport, WS, PROJECT, fakeLog()); - expect([...roots.keys()]).to.have.members(['category', 'intent', 'source', 'type']); - expect(values.get('source')?.get('ai')).to.equal(TAG_IDS.sourceAi); + expect([...roots.keys()]).to.have.members(['category', 'intent', 'origin', 'type']); + expect(values.get('origin')?.get('ai')).to.equal(TAG_IDS.originAi); expect(values.get('type')?.get('branded')).to.equal(TAG_IDS.typeBranded); // The open `category` root is provisioned but its children are customer content. expect(values.has('category')).to.equal(false); @@ -352,25 +452,25 @@ describe('serenity tag-tree', () => { listProjectTags: makeListProjectTagsStub(), createProjectTags: sinon.stub(), }; - const res = await ensureClosedValue(transport, WS, PROJECT, 'source', 'ai', fakeLog()); + const res = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()); expect(res).to.deep.equal({ - id: TAG_IDS.sourceAi, rootId: TAG_IDS.sourceRoot, created: false, + id: TAG_IDS.originAi, rootId: TAG_IDS.originRoot, created: false, }); expect(transport.createProjectTags).to.not.have.been.called; }); it('creates a missing value under its root and reports created:true', async () => { const levels = dimensionTreeLevels(); - levels[TAG_IDS.sourceRoot] = []; + levels[TAG_IDS.originRoot] = []; const transport = { listProjectTags: makeListProjectTagsStub(levels), createProjectTags: sinon.stub().resolves([ - { id: 'made-ai', name: 'ai', parent_id: TAG_IDS.sourceRoot }, + { id: 'made-ai', name: 'ai', parent_id: TAG_IDS.originRoot }, ]), }; - const res = await ensureClosedValue(transport, WS, PROJECT, 'source', 'ai', fakeLog()); + const res = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()); expect(res).to.deep.equal({ - id: 'made-ai', rootId: TAG_IDS.sourceRoot, created: true, + id: 'made-ai', rootId: TAG_IDS.originRoot, created: true, }); }); @@ -380,7 +480,7 @@ describe('serenity tag-tree', () => { // The create echoes nothing, so no root id is ever learned. createProjectTags: sinon.stub().resolves([]), }; - const err = await ensureClosedValue(transport, WS, PROJECT, 'source', 'ai', fakeLog()) + const err = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()) .then(() => null, (e) => e); expect(err).to.be.an('error'); expect(err.status).to.equal(502); @@ -467,10 +567,10 @@ describe('serenity tag-tree', () => { transport, WS, PROJECT, - [TAG_IDS.sourceHuman, 'no-such-tag'], + [TAG_IDS.originHuman, 'no-such-tag'], fakeLog(), ); - expect(found.get(TAG_IDS.sourceHuman).rootName).to.equal('source'); + expect(found.get(TAG_IDS.originHuman).rootName).to.equal('origin'); expect(found.get('no-such-tag')).to.deep.equal({ kind: 'unknown', parentId: null, rootName: null, ancestorIds: [], }); @@ -505,9 +605,9 @@ describe('serenity tag-tree', () => { it('reports the same bare name under a different root as that other dimension', async () => { const transport = { listProjectTags: makeListProjectTagsStub() }; - const placed = await findTagsInTree(transport, WS, PROJECT, [TAG_IDS.sourceHuman], fakeLog()); - const found = placed.get(TAG_IDS.sourceHuman); - expect(found.rootName).to.equal('source'); + const placed = await findTagsInTree(transport, WS, PROJECT, [TAG_IDS.originHuman], fakeLog()); + const found = placed.get(TAG_IDS.originHuman); + expect(found.rootName).to.equal('origin'); }); it('reports an id absent from the tree as unknown', async () => { @@ -582,7 +682,7 @@ describe('serenity tag-tree', () => { // The cheap check — comparing against the three closed root ids — passes here. // Ancestry is what catches it. const transport = { listProjectTags: makeListProjectTagsStub() }; - const err = await assertParentWithinDimension(transport, WS, PROJECT, 'category', TAG_IDS.sourceHuman, fakeLog()).then(() => null, (e) => e); + const err = await assertParentWithinDimension(transport, WS, PROJECT, 'category', TAG_IDS.originHuman, fakeLog()).then(() => null, (e) => e); expect(err.status).to.equal(400); });