Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/support/serenity/handlers/markets-subworkspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions src/support/serenity/handlers/tags.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 35 additions & 16 deletions src/support/serenity/prompt-tags.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
*/

/**
Expand All @@ -40,20 +45,27 @@
export const DIMENSION = Object.freeze({
CATEGORY: 'category',
INTENT: 'intent',
SOURCE: 'source',
ORIGIN: 'origin',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authorship dimension root renamed sourceorigin. This is the canonical name the rest of the program (WP-O2a…O6) keys on; the legacy physical source root is tolerated at the resolver layer, not here.

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';

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New LEGACY_AUTHORSHIP_ROOT_NAME = 'source' — the single source of truth for the pre-rename name that tag-tree.js matches against. Good that it's a named export rather than a string literal buried in the resolver, and the doc explicitly scopes its lifetime to WP-O6.


/** 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({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SOURCE_VALUEORIGIN_VALUE; values (ai/human) unchanged. The vocabulary is what childrenAreAuthorship diffs against, so keeping the values stable is what lets a legacy source root be recognized as authorship.

AI: 'ai',
HUMAN: 'human',
});
Expand Down Expand Up @@ -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,
]);

Expand All @@ -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 }),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standard AI-prompt seed now emits { dimension: origin, name: ai }. Callers resolve this pair against the project tree via ensureClosedValue, which inherits the resolver's tolerance — so on a legacy source project this still resolves to the physical source root's ai child.

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) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isDimensionRootName now also reserves the legacy source name (not just the four current roots), which is the right call — it prevents a customer from minting a category/tag named source that could collide with (or be confused for) the legacy authorship root the resolver still adopts. Verified this isn't a new restriction in practice: source was already reserved pre-rename as the (then-current) authorship root name, so no existing customer-tag-creation behavior regresses here — it's continuity, not a new restriction, until WP-O6 drops it.

return (/** @type {readonly string[]} */ (DIMENSION_ROOT_NAMES)).includes(name);
return name === LEGACY_AUTHORSHIP_ROOT_NAME

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MysticatBot suggestion #3. isDimensionRootName now also reserves the legacy authorship name during the rename window, so a customer cannot mint a source tag that would collide with the legacy root the resolver still adopts. Extending the shared helper (rather than each call site) covers both handleCreateTag and handleUpdateTag at once. Scoped to WP-O6 in the JSDoc alongside the rest of the fallback.

|| (/** @type {readonly string[]} */ (DIMENSION_ROOT_NAMES)).includes(name);
}

/**
Expand Down
86 changes: 80 additions & 6 deletions src/support/serenity/tag-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MysticatBot nit #2. Stale file-header reference the \source` value `human`origin`.

* - 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
Expand All @@ -51,6 +51,8 @@ import {
DIMENSION_ROOT_NAMES,
CLOSED_DIMENSION_VALUES,
CLOSED_DIMENSIONS,
ORIGIN_VALUE,
LEGACY_AUTHORSHIP_ROOT_NAME,
} from './prompt-tags.js';

/**
Expand Down Expand Up @@ -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<string, string>} [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<string, string>, createdNames: string[] }>}
* `byName` maps every wanted name to its tag id.
*/
Expand All @@ -138,8 +143,10 @@ export async function ensureChildren(
parentId,
wanted,
log,
preRead,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New optional preRead param on ensureChildren, reused by ensureDimensionRoots to avoid a second root-level read after the tolerant resolve already did one. Correct as implemented (existing = preRead ?? await indexLevelByName(...)); the only nit is there's no test asserting the read isn't duplicated (an interaction/call-count test), so this optimization's correctness rests on code inspection rather than a regression-guarding test. Not blocking.

) {
const existing = await indexLevelByName(transport, semrushWorkspaceId, projectId, parentId, log);
const existing = preRead

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preRead optional arg lets the tolerant resolver hand in the root-level index it already read, so the resolve costs no extra root read on the common path. Note it's used only for the optimistic first pass — the create-race catch (below) and the unechoed re-read both still fetch a fresh level, so race semantics are unchanged. One subtlety: existing is mutated in place with echoed nodes, so the caller's preRead map is mutated too; benign here because ensureDimensionRoots only reads from the returned byName afterward.

?? await indexLevelByName(transport, semrushWorkspaceId, projectId, parentId, log);
const missing = wanted.filter((name) => !existing.has(name));
if (missing.length === 0) {
return { byName: existing, createdNames: [] };
Expand Down Expand Up @@ -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<boolean>}
*/
async function childrenAreAuthorship(transport, semrushWorkspaceId, projectId, rootId, log) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard is the crux of the whole tolerance scheme — it's what keeps the #47 producing-system source from being mistaken for authorship. Discussion: .every() on an empty child set returns true, so a childless source root is adopted as authorship (documented as intentional). That's correct while authorship source is the only source, but once #47's companion source dimension exists, a freshly-created-yet-empty producing-system source on an origin-less project would be mis-adopted. Given it's removed at WP-O6 and the window is narrow, I read this as an accepted bounded risk — but worth a one-line note in the doc calling out that the vacuous-true assumes authorship is the only source until #47 lands.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

childrenAreAuthorship: the vacuous-true case (empty children set) is a deliberate design choice per the docstring — an unpopulated root can't yet disagree with authorship, so it's adopted rather than shadowed by a fresh origin. Confirmed this is exercised by the "CHILDLESS legacy source root" test. Agreed this is correct: a blind reject here would force a spurious second root the first time a project's source root simply hasn't been populated yet.

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<Map<string, string>>} root name → tag id, for all four roots.
* @returns {Promise<Map<string, string>>} 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) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureDimensionRoots: traced the full branch matrix (origin-exists / legacy-adopt / legacy-reject-mint-origin / childless-legacy-adopt / neither-exists) against the four new tests plus the two pre-existing ones — all consistent, no case where a second authorship root could be minted alongside an already-resolved one. The wanted filter (name !== DIMENSION.ORIGIN || !authorshipId) correctly excludes origin from the create pass exactly when an authorship root (real or legacy) was already found, and the final roots map always keys the authorship dimension under DIMENSION.ORIGIN regardless of which physical root backs it — so callers (provisionDimensionTree, ensureClosedValue) get the tolerance for free, verified in the diff below.

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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tolerant resolution order is right: prefer a real origin root, and only then consider adopting a legacy source. Because origin wins first, a partially-migrated project that has both roots resolves to origin and leaves the orphan source untouched (cleaned up at WP-O6) — no second root, no ambiguity.

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(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wanted filter is what guarantees "never mint a second authorship root": origin is dropped from the create batch whenever authorshipId was resolved (via origin or adopted source), so a create can only produce origin on a genuinely fresh project. Fresh-project path stays a single batched create.

(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),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return-map assembly: iterating DIMENSION_ROOT_NAMES fixes key order to [category, intent, origin, type] regardless of physical root, and authorshipId ?? byName.get(name) puts the adopted/created id under the origin key. Correct for both branches — when authorshipId is set, origin was filtered from wanted so byName has no origin entry and the ?? falls through to the resolved id; when unset, byName.get('origin') holds the freshly-created id.

);
}
return roots;
}

/**
Expand Down Expand Up @@ -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`).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MysticatBot nit #1. Stale @param dimension JSDoc intent / source / typeintent / 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`
Expand Down
14 changes: 7 additions & 7 deletions test/it/shared/tests/serenity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI fix (it-postgres failure #1). Root-list assertion renamed sourceorigin. The list endpoint returns the physical root names, and post-rename the resolver provisions/adopts an origin root — the expected [origin] to have members [source] mismatch was purely this stale expectation.

const categoryRoot = roots.body.items.find((t) => t.name === 'category');
expect(res.body.parentId).to.equal(categoryRoot.id);
});
Expand Down Expand Up @@ -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 () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI fix (it-postgres failure #2). This case POSTed type: 'source', which parseCreateTagBody now rejects (400) since source is no longer in ALL_DIMENSIONS. Renamed the dimension to origin in the title and all three request bodies below — the closed value resolves against the authorship root (whether the mock seeds a real origin or a legacy source the resolver adopts), so it now returns 200 idempotently.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment updated to origin root for consistency. Note: the separate source prompt filter in categories-prompts.js (source: 'gsc' | 'config') is the producing-system dimension from serenity-docs#47 — a different source, correctly NOT renamed by this PR.

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 });
Expand All @@ -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}`, {
Expand Down
Loading
Loading