diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index 781597e454..85fff1f48f 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -11017,6 +11017,34 @@ SerenityPrompt: Present on a create/update response (`SerenityCreatePromptsResponse.created`, the `updateSerenityPrompt` 200); absent on `GET /serenity/prompts` list items, which carry `tags`. + createdAt: + type: [string, 'null'] + format: date-time + description: | + RFC 3339 UTC timestamp the prompt was first created, from the + server-owned authorship metadata. Present on `GET /serenity/prompts` + list items; null for a prompt created before authorship stamping (an + un-backfilled prompt). + createdBy: + type: [string, 'null'] + maxLength: 100 + description: | + Opaque id of the caller that created the prompt (an IMS user id, or the + literal `unknown` when the author could not be resolved). Resolve to a + display name via the userDetails lookup. Null for an un-backfilled prompt. + updatedAt: + type: [string, 'null'] + format: date-time + description: | + RFC 3339 UTC timestamp of the prompt's last in-place edit (its text or + tags). Equal to `createdAt` for a never-edited prompt. Null for an + un-backfilled prompt. + updatedBy: + type: [string, 'null'] + maxLength: 100 + description: | + Opaque id of the caller that last edited the prompt (or the literal + `unknown`). Null for an un-backfilled prompt. SerenityPromptTag: type: object diff --git a/docs/openapi/serenity-api.yaml b/docs/openapi/serenity-api.yaml index e309d4cde2..0b8ae6cbaf 100644 --- a/docs/openapi/serenity-api.yaml +++ b/docs/openapi/serenity-api.yaml @@ -81,6 +81,27 @@ v2-serenity-prompts: Maximum 50 ids; excess values are silently truncated (same pattern as the `limit` cap). This matches the server-side `MAX_TAG_IDS` constant in `handlers/prompts.js`. + - name: sort + in: query + required: false + schema: + type: string + enum: [metadata.created_at, metadata.updated_at] + description: | + Sort the page by a server-owned authorship timestamp. Restricted to + `metadata.created_at` (Created) or `metadata.updated_at` (Last + modified) — any other value is a 400. Omitted leaves the upstream + default (unsorted) ordering. Requires the with-metadata list path. + - name: order + in: query + required: false + schema: + type: string + enum: [asc, desc] + default: desc + description: | + Sort direction for `sort`; defaults to `desc` (newest first). Ignored + when `sort` is omitted. responses: '200': description: Prompts retrieved successfully. diff --git a/src/controllers/serenity.js b/src/controllers/serenity.js index 334ecd4ca7..ec9f767f7c 100644 --- a/src/controllers/serenity.js +++ b/src/controllers/serenity.js @@ -29,6 +29,7 @@ import { handleCreatePrompts, handleUpdatePrompt, handleBulkDeletePrompts, + resolveCallerId, } from '../support/serenity/handlers/prompts.js'; import { handleListMarkets, @@ -521,6 +522,10 @@ function SerenityController(context, log, env) { } const transport = buildTransport(ctx, imsToken); const classifyPromptType = await buildPromptTypeClassifier(ctx, auth.brandUuid); + // CORRECTNESS-CRITICAL (LLMO-6289): resolve the caller identity ONCE, from + // the request's auth profile — NEVER from the bearer forwarded upstream — + // and thread it into every write below to stamp created_*/updated_*. + const callerId = resolveCallerId(ctx); const result = auth.mode === 'subworkspace' ? await handleCreatePromptsSubworkspace( transport, @@ -528,6 +533,7 @@ function SerenityController(context, log, env) { ctx.data || {}, log, classifyPromptType, + callerId, { dynamicAllocation: dynamicAllocationEnabled(ctx), parentWorkspaceId: auth.parentWorkspaceId ?? '', @@ -541,6 +547,7 @@ function SerenityController(context, log, env) { ctx.data || {}, log, classifyPromptType, + callerId, ); return createResponse(result, 200); } catch (e) { @@ -561,6 +568,9 @@ function SerenityController(context, log, env) { } const transport = buildTransport(ctx, imsToken); const classifyPromptType = await buildPromptTypeClassifier(ctx, auth.brandUuid); + // Caller identity for the updated_* stamp — resolved from the auth profile, + // never the forwarded upstream bearer (LLMO-6289). + const callerId = resolveCallerId(ctx); const result = auth.mode === 'subworkspace' ? await handleUpdatePromptSubworkspace( transport, @@ -569,6 +579,7 @@ function SerenityController(context, log, env) { ctx.data || {}, log, classifyPromptType, + callerId, ) : await handleUpdatePrompt( transport, @@ -579,6 +590,7 @@ function SerenityController(context, log, env) { ctx.data || {}, log, classifyPromptType, + callerId, ); return createResponse(result.body, result.status); } catch (e) { @@ -723,6 +735,9 @@ function SerenityController(context, log, env) { // Dynamic-allocation kill-switch. The JIT top-up units pool is the org parent passed // positionally above (auth.parentWorkspaceId) — not duplicated in this options bag. dynamicAllocation: dynamicAllocationEnabled(ctx), + // Caller identity for the created_* stamp on any generated prompt + // (LLMO-6289) — from the auth profile, never the upstream bearer. + callerId: resolveCallerId(ctx), }, ); // Mirror this market as a SpaceCat Site (+ brand_sites link) keyed on the @@ -1227,6 +1242,10 @@ function SerenityController(context, log, env) { dynamicAllocation: dynamicAllocationEnabled(ctx), }, ); + // Caller identity for the created_* stamp on generated prompts, resolved + // ONCE for the whole activate batch (LLMO-6289) — from the auth profile, + // never the forwarded upstream bearer. + const callerId = resolveCallerId(ctx); const results = []; for (const m of markets) { const createBody = { @@ -1276,6 +1295,7 @@ function SerenityController(context, log, env) { dataAccess: { BrandSemrushProject: ctx.dataAccess.BrandSemrushProject }, // JIT units pool = the org parent passed positionally above; not duplicated here. dynamicAllocation: dynamicAllocationEnabled(ctx), + callerId, }, ); } catch (e) { diff --git a/src/support/serenity/brand-provisioning.js b/src/support/serenity/brand-provisioning.js index dc3bc9e861..744568c54d 100644 --- a/src/support/serenity/brand-provisioning.js +++ b/src/support/serenity/brand-provisioning.js @@ -20,6 +20,7 @@ import { isSemrushTransportError } from './errors.js'; import { resolveWorkspaceId } from './workspace-resolver.js'; import { deleteAllProjects, releaseFullAllocation } from './workspace-lifecycle.js'; import { handleCreateMarketSubworkspace } from './handlers/markets-subworkspace.js'; +import { resolveCallerId } from './handlers/prompts.js'; // Re-exported for callers/tests that drive brand provisioning. The tag // vocabularies themselves live in `prompt-tags.js` (single source of truth). @@ -217,6 +218,11 @@ export async function provisionBrandSubworkspace(context, { publishMode: (Array.isArray(modelIds) && modelIds.length > 0) || generateTopics ? 'require' : 'best-effort', + // Caller identity for the created_* stamp on generated prompts (LLMO-6289), + // resolved from the request auth profile — never the forwarded upstream + // bearer. This create runs before the brand row exists; the caller + // (brands.js POST /brands) is the human/service provisioning the brand. + callerId: resolveCallerId(context), }, ); } catch (e) { diff --git a/src/support/serenity/handlers/markets-subworkspace.js b/src/support/serenity/handlers/markets-subworkspace.js index 2534d27c9d..80b41b7ab2 100644 --- a/src/support/serenity/handlers/markets-subworkspace.js +++ b/src/support/serenity/handlers/markets-subworkspace.js @@ -19,6 +19,7 @@ import { ERROR_CODES, isUpstreamGone, isMeteredQuota, toQuotaExceededError, } from '../errors.js'; import { normalizeGeoTargetId, normalizeLanguageCode } from '../validation.js'; +import { buildCreateMetadata } from './prompts.js'; import { resolveLocation, resolveLanguageId, @@ -190,7 +191,7 @@ function validateCreateBody(body) { * there is no correct parent to create them below. Generated prompts therefore * arrive uncategorized and are categorized later (adobe/serenity-docs#44). * - * Writes are id-based: `createPromptsByIds` takes ONE shared `tag_ids` array per + * Writes are id-based: `createPromptsWithMetadata` takes ONE shared `tag_ids` array per * call, so the texts are partitioned by their resolved tag-id set — which, with * topics gone, is exactly two groups (branded and non-branded). Identical text * collapses to one entry per group. @@ -214,12 +215,14 @@ function validateCreateBody(body) { * REQUIRED, not optional: a genuine no-op object when the flag is OFF, never `undefined`. Not * optional-chained at the call site below (Rainer review) — a caller that forgets to thread it * must fail loud, not silently skip metering. PROMPT metering seam (Rainer, live-verified - * LLMO-6190): the metered write is `createPromptsByIds` below, NOT publish — front it BEFORE the - * write loop, sized on the real prompt count now that it's known (`texts.size`), not an estimate. + * LLMO-6190): the metered write is `createPromptsWithMetadata` below, NOT publish — front it + * BEFORE the write loop, sized on the real prompt count now known (`texts.size`), not estimated. + * @param {string} callerId - resolved caller id (see `resolveCallerId`) stamped as + * `created_by`/`updated_by` on every generated prompt (LLMO-6289). */ async function generateAndAttachPrompts(transport, workspaceId, projectId, { domain, country, topicCap = 0, brandNames = [], provisioned, -}, log, headroom) { +}, log, headroom, callerId) { const raw = await transport.getBrandTopics(workspaceId, { domain, country }); let topics = []; if (Array.isArray(raw)) { @@ -255,7 +258,7 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, { return { topicCount: 0, promptCount: 0 }; } - // Resolve every tag id we are about to attach. `createPromptsByIds` is ATOMIC on + // Resolve every tag id we are about to attach. `createPromptsWithMetadata` is ATOMIC on // an unresolvable id (live 500s and creates nothing), so ids are never guessed. // `provisionDimensionTree` resolved every closed value or threw a 502, so the // standard values and the whole `type` vocabulary are present here by construction. @@ -279,6 +282,10 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, { await headroom.ensure({ prompts: texts.size }, { includeDrafted: true }); + // STAMP (LLMO-6289): AI-generated prompts are created through the v3 + // metadata-carrying write, `created_* = updated_* = now / callerId`. One + // metadata object per batch (same instant for every text in the group). + const metadata = buildCreateMetadata(callerId); for (const [value, items] of byTypeValue) { // `branded` / `non-branded` are the classifier's only outputs and both are in // the `type` vocabulary provisioned above. @@ -288,8 +295,13 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, { // top-up) — route through `headroom.retryOnQuota` (no-op passthrough when the flag is OFF). // eslint-disable-next-line no-await-in-loop await headroom.retryOnQuota( - () => transport.createPromptsByIds(workspaceId, projectId, items, [...standardIds, typeId]), - { callSite: 'createPromptsByIds' }, + () => transport.createPromptsWithMetadata( + workspaceId, + projectId, + items.map((name) => ({ name, metadata })), + [...standardIds, typeId], + ), + { callSite: 'createPromptsWithMetadata' }, ); } return { topicCount: selected.length, promptCount: texts.size }; @@ -357,6 +369,10 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, { * `ensureSubworkspace` is skipped. When false, byte-for-byte the pre-this-PR behavior. * (The units pool for JIT sizing is the positional `parentWorkspaceId` arg — the same id given * to `ensureSubworkspace` — so it is not duplicated in this options bag.) + * @param {string} [options.callerId='unknown'] - resolved caller id (see + * `resolveCallerId`) stamped as `created_by`/`updated_by` on any AI-generated + * prompt this create attaches (LLMO-6289). Defaults to the `unknown` sentinel + * so a caller that omits it never writes an empty author. */ export async function handleCreateMarketSubworkspace( transport, @@ -376,6 +392,7 @@ export async function handleCreateMarketSubworkspace( publishMode = 'require', dataAccess = null, dynamicAllocation = false, + callerId = 'unknown', } = {}, ) { const errors = validateCreateBody(body); @@ -508,6 +525,7 @@ export async function handleCreateMarketSubworkspace( }, log, headroom, + callerId, ); } diff --git a/src/support/serenity/handlers/prompts-subworkspace.js b/src/support/serenity/handlers/prompts-subworkspace.js index 306c1704f9..a770f25110 100644 --- a/src/support/serenity/handlers/prompts-subworkspace.js +++ b/src/support/serenity/handlers/prompts-subworkspace.js @@ -26,6 +26,8 @@ import { parseUpdatePromptBody, mapLimit, publishAffected, + resolveSort, + buildUpdateMetadata, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, MAX_TAG_IDS, @@ -79,6 +81,9 @@ export async function handleListPromptsSubworkspace(transport, workspaceId, quer const tagIds = Array.isArray(query?.tagIds) ? query.tagIds.slice(0, MAX_TAG_IDS).map(String).filter(Boolean) : []; + // sort/order (LLMO-6289): validated against the metadata allow-list and + // forwarded upstream — kept in lockstep with the flat-mode twin. + const { sort, order } = resolveSort(query); const project = await resolveProject(transport, workspaceId, geoTargetId, languageCode, log); if (!project) { @@ -97,6 +102,8 @@ export async function handleListPromptsSubworkspace(transport, workspaceId, quer page, limit, search, + sort, + order, }); const items = Array.isArray(resp?.items) ? resp.items : []; let total; @@ -126,6 +133,7 @@ export async function handleCreatePromptsSubworkspace( body, log, classifyPromptType, + callerId, { dynamicAllocation = false, parentWorkspaceId = '' } = {}, ) { const inputs = Array.isArray(body?.prompts) ? body.prompts : []; @@ -143,7 +151,7 @@ export async function handleCreatePromptsSubworkspace( const injectComputedType = makeTypeInjector(transport, workspaceId, classifyPromptType, log); // PROMPT metering seam (Rainer, live-verified LLMO-6190): the metered write is - // `createPromptsByIds` (inside `createOnePrompt` below), NOT publish — a disguised-quota 405 + // `createPromptsWithMetadata` (inside `createOnePrompt` below), NOT publish — a disguised 405 // fires there, before any publish, if `used + drafted + batch > total`. Front headroom BEFORE // this loop, sized on the whole incoming batch (`inputs.length` — a safe upper bound; some // inputs may still skip on validation/missing-project, so this can over-provision slightly, never @@ -184,7 +192,7 @@ export async function handleCreatePromptsSubworkspace( // no-op passthrough when the flag is OFF) so each item recovers independently; `mapLimit`'s // own per-item try/catch below still isolates a surviving failure to this one item. const semrushPromptId = await headroom.retryOnQuota( - () => createOnePrompt(transport, workspaceId, projectId, typed), + () => createOnePrompt(transport, workspaceId, projectId, typed, callerId), { callSite: 'createOnePrompt' }, ); return { @@ -254,10 +262,12 @@ export async function handleCreatePromptsSubworkspace( * PATCH /serenity/prompts/:semrushPromptId (subworkspace) — in-place edit. * Resolves the slice's project from the live listing, then edits the prompt IN * PLACE exactly like the flat-mode twin (see handleUpdatePrompt's contract): - * `rename` first (the one op that can refuse — upstream 404 → promptNotFound, - * 409 text collision → thrown for the controller's `conflict` mapping), then - * the replace-mode batch tag write. The prompt id is preserved end to end and - * echoed unchanged in the response; nothing is deleted on this path. + * the combined v3 `PATCH .../{id}` first (text `name` + `updated_*` metadata + * stamp in one request — the one op that can refuse: upstream 404 → + * promptNotFound, 409 text collision → thrown for the controller's `conflict` + * mapping), then the replace-mode batch tag write (v2, metadata-free). The + * prompt id is preserved end to end and echoed unchanged in the response; + * nothing is deleted on this path. */ export async function handleUpdatePromptSubworkspace( transport, @@ -266,6 +276,7 @@ export async function handleUpdatePromptSubworkspace( body, log, classifyPromptType, + callerId, ) { const parsedBody = parseUpdatePromptBody(body); if (!parsedBody.ok) { @@ -305,7 +316,13 @@ export async function handleUpdatePromptSubworkspace( }); try { - await transport.renamePrompt(workspaceId, projectId, semrushPromptId, nextText); + // Combined v3 write: text (`name`) + `updated_*` stamp in one request + // (replaces the v2 `rename`). Same refusal contract — 404 → promptNotFound, + // 409 (sibling text collision) → thrown for the controller's `conflict`. + await transport.patchPrompt(workspaceId, projectId, semrushPromptId, { + name: nextText, + metadata: buildUpdateMetadata(callerId), + }); } catch (e) { if (isUpstreamGone(e)) { return { @@ -334,7 +351,7 @@ export async function handleUpdatePromptSubworkspace( // The rename above already landed: the prompt's text has moved while its // tags are stale. Record the partial mutation before propagating, so the // generic upstream error the caller sees is attributable on-call. - log?.warn?.('updatePromptTagsByIds failed after a successful rename — text updated, tags stale', { + log?.warn?.('updatePromptTagsByIds failed after a successful text/metadata PATCH — text updated, tags stale', { semrushPromptId, projectId, error: e.message, }); throw e; diff --git a/src/support/serenity/handlers/prompts.js b/src/support/serenity/handlers/prompts.js index 75e71f540f..0cc0a77a16 100644 --- a/src/support/serenity/handlers/prompts.js +++ b/src/support/serenity/handlers/prompts.js @@ -48,6 +48,109 @@ export const BULK_CREATE_CONCURRENCY = 8; // of them. Defense-in-depth, not a correctness gate. export const BULK_PROMPTS_MAX_ITEMS = 500; +// Server-owned prompt-authorship metadata (LLMO-6289, serenity-docs prompt- +// authorship-metadata spec). The four keys stamped on Semrush's Adobe-owned +// `metadata` JSONB column. Values are opaque caller ids (resolved by +// resolveCallerId, capped at 100 chars — the upstream CHECK bound) and RFC 3339 +// UTC timestamps. +// +// SORT allow-list: the only two fields the with-metadata list read may sort on. +// A `sort` outside this set is a 400 — the value is forwarded verbatim upstream, +// so the allow-list is the injection guard, not merely input hygiene. +export const SORTABLE_METADATA_FIELDS = ['metadata.created_at', 'metadata.updated_at']; +export const SORT_ORDERS = ['asc', 'desc']; +// created_by / updated_by carry a CHECK(length <= 100) upstream; a longer value +// is a 400 (and rolls a batch back). resolveCallerId is the SINGLE resolution +// point and caps here so no write path can exceed it. +export const CALLER_ID_MAX_LENGTH = 100; + +/** + * Resolves the opaque caller id to stamp on a write, from the request's auth + * profile. CORRECTNESS-CRITICAL: authorship is the CALLER's identity, resolved + * from `authInfo.getProfile()` — NEVER from the bearer forwarded upstream, whose + * principal can differ from the caller after the promise-token exchange. + * + * `user_id` is preferred; `sub` is the fallback (both IMS profile claims). A + * missing/blank identity becomes the literal `unknown` (the spec's NULL-author + * sentinel, resolved to a display name downstream). Capped at + * {@link CALLER_ID_MAX_LENGTH} so a pathological claim can never trip the + * upstream length CHECK (which would 400 the write / roll a batch back). + * + * @param {object} ctx - the controller request context. + * @returns {string} the caller id, `unknown` when unresolved, ≤100 chars. + */ +export function resolveCallerId(ctx) { + const profile = ctx?.attributes?.authInfo?.getProfile?.(); + const raw = profile?.user_id ?? profile?.sub; + const id = hasText(raw) ? String(raw) : 'unknown'; + return id.slice(0, CALLER_ID_MAX_LENGTH); +} + +/** + * Builds the `metadata` merge-patch payload for a CREATE: all four keys, with + * `created_*` and `updated_*` set to the SAME instant/caller (a create is its + * own first edit). Timestamps are RFC 3339 UTC (`new Date().toISOString()`). + * + * ONE stamping helper, shared by BOTH prompt twins (flat + subworkspace) and the + * AI-generation create — the metadata logic is never written twice. + * + * @param {string} callerId - already resolved + capped by {@link resolveCallerId}. + */ +export function buildCreateMetadata(callerId) { + const now = new Date().toISOString(); + return { + created_at: now, + created_by: callerId, + updated_at: now, + updated_by: callerId, + }; +} + +/** + * Builds the `metadata` merge-patch payload for an EDIT: ONLY the `updated_*` + * pair (RFC 7396 merge semantics — absent keys are kept, so `created_*` survive + * untouched with no read-before-write). Timestamp is RFC 3339 UTC. + * + * @param {string} callerId - already resolved + capped by {@link resolveCallerId}. + */ +export function buildUpdateMetadata(callerId) { + return { + updated_at: new Date().toISOString(), + updated_by: callerId, + }; +} + +/** + * Validates + normalizes the `sort` / `order` list query params against the + * {@link SORTABLE_METADATA_FIELDS} allow-list. Returns `{}` when neither is + * supplied (the legacy unsorted read); `{ sort, order }` when a valid sort is + * requested (order defaults to `desc` — newest first for "Last modified"); + * throws 400 for an unknown sort field or order. + * + * @param {object} query + * @returns {{ sort?: string, order?: string }} + */ +export function resolveSort(query) { + const sort = query?.sort; + const order = query?.order; + if (sort === undefined || sort === null || sort === '') { + return {}; + } + if (!SORTABLE_METADATA_FIELDS.includes(sort)) { + throw new ErrorWithStatusCode( + `sort must be one of: ${SORTABLE_METADATA_FIELDS.join(', ')}`, + 400, + ); + } + const normalizedOrder = order === undefined || order === null || order === '' + ? 'desc' + : String(order).toLowerCase(); + if (!SORT_ORDERS.includes(normalizedOrder)) { + throw new ErrorWithStatusCode('order must be one of: asc, desc', 400); + } + return { sort, order: normalizedOrder }; +} + /** * Builds the prompt's tag list from the upstream item: one entry per tag, * carrying its id, bare name, parent id and root-first ancestry breadcrumb. @@ -125,6 +228,11 @@ export function buildPromptDto(geoTargetId, languageCode, item) { if (!text) { return null; } + // Server-owned authorship metadata (LLMO-6289): the with-metadata list read + // carries Semrush's Adobe-owned `metadata` column inline on each item. Map its + // snake_case keys to the camelCase DTO fields; null when the item predates a + // stamp (an un-backfilled prompt) so the shape is stable for the UI's em-dash. + const metadata = item?.metadata; return { semrushPromptId: String(item?.id ?? ''), geoTargetId, @@ -132,6 +240,10 @@ export function buildPromptDto(geoTargetId, languageCode, item) { text, tags: buildTagsOf(item), tagMap: buildTagMapOf(item), + createdAt: metadata?.created_at ?? null, + createdBy: metadata?.created_by ?? null, + updatedAt: metadata?.updated_at ?? null, + updatedBy: metadata?.updated_by ?? null, }; } @@ -173,6 +285,10 @@ export async function handleListPrompts( const tagIds = Array.isArray(query?.tagIds) ? query.tagIds.slice(0, MAX_TAG_IDS).map(String).filter(Boolean) : []; + // sort/order (LLMO-6289): validated against the metadata allow-list and + // forwarded upstream on the (now metadata-carrying) by_tags read. `{}` when + // unspecified — byte-for-byte the legacy unsorted call. + const { sort, order } = resolveSort(query); const row = await dataAccess.BrandSemrushProject.findBySlice( brandId, @@ -207,6 +323,8 @@ export async function handleListPrompts( page, limit, search, + sort, + order, }, ); const items = Array.isArray(resp?.items) ? resp.items : []; @@ -346,18 +464,24 @@ export function normalizePromptInput(input) { * — so every id must already be a known-good upstream tag id, resolved by the * caller and never guessed. * + * STAMPS create authorship (LLMO-6289): every create goes through the v3 + * `createPromptsWithMetadata` write carrying `created_* = updated_* = now / + * callerId`. The metadata rides the same write as the create — nothing to + * sequence, no read-before-write. + * * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {string} projectId * @param {{ text: string, tagIds: string[] }} input + * @param {string} callerId - resolved caller id (see {@link resolveCallerId}). * @returns {Promise} the new upstream prompt id, or '' if the * response carried none. */ -export async function createOnePrompt(transport, semrushWorkspaceId, projectId, input) { - const resp = await transport.createPromptsByIds( +export async function createOnePrompt(transport, semrushWorkspaceId, projectId, input, callerId) { + const resp = await transport.createPromptsWithMetadata( semrushWorkspaceId, projectId, - [input.text], + [{ name: input.text, metadata: buildCreateMetadata(callerId) }], input.tagIds, ); return Array.isArray(resp?.items) && resp.items.length > 0 @@ -374,7 +498,7 @@ export async function createOnePrompt(transport, semrushWorkspaceId, projectId, * - STRIPS every caller-supplied tag id that lives under the `type` root (the * client may never set the value), and * - APPENDS the pre-resolved upstream id of the server-computed value. The - * atomic `createPromptsByIds` 500s on an unresolved id, so it is resolved + * atomic `createPromptsWithMetadata` 500s on an unresolved id, so it is resolved * BEFORE the write. * The returned input carries the rewritten `tagIds`, so the caller's response * echo reflects the computed type without a refetch (decision 5). @@ -507,6 +631,7 @@ export async function handleCreatePrompts( body, log, classifyPromptType, + callerId, ) { const inputs = Array.isArray(body?.prompts) ? body.prompts : []; if (inputs.length === 0) { @@ -560,6 +685,7 @@ export async function handleCreatePrompts( semrushWorkspaceId, projectId, typed, + callerId, ); return { created: { @@ -637,17 +763,26 @@ export async function handleCreatePrompts( * side a single straight line and removes the per-request pagination that * "preserve-on-omit" semantics would force. * - * The edit is IN PLACE (serenity-docs#63): `rename` writes the text and the - * batch tag-reference write replaces the tag set, both preserving the prompt - * id — the response echoes the UNCHANGED semrushPromptId, and everything keyed - * to that id survives the edit. Nothing is deleted on this path, so there is - * no data-loss window. Both writes run unconditionally: upstream has no - * GET-by-id, so the handler cannot know what changed — and does not need to - * (an unchanged-text rename is a documented `is_updated: false` no-op, and the - * replace-mode tag write is idempotent). Rename runs FIRST because it is the - * one operation that can refuse (409): a collision aborts the edit before any - * mutation. A tag-write failure after a successful rename leaves a - * half-applied edit (text updated, tags not) — retryable, nothing lost. + * The edit is IN PLACE (serenity-docs#63): the combined v3 `PATCH .../{id}` + * writes the text (as `name`) AND stamps the `updated_*` metadata pair in ONE + * request, then the batch tag-reference write (v2 `PUT .../tags`, metadata-free) + * replaces the tag set — both preserving the prompt id, so the response echoes + * the UNCHANGED semrushPromptId and everything keyed to that id survives the + * edit. Nothing is deleted on this path, so there is no data-loss window. Both + * writes run unconditionally: upstream has no GET-by-id, so the handler cannot + * know what changed — and does not need to (an unchanged-text combined PATCH + * still merge-patches the metadata, and the replace-mode tag write is + * idempotent). The combined PATCH runs FIRST because it is the one operation + * that can refuse (409): a collision aborts the edit before any mutation. + * + * STAMP (LLMO-6289): the `updated_*` bump rides the combined text PATCH — the + * SAME request as the text mutation, with NO read-before-write — so authorship + * is stamped on every edit. The tag PUT carries NO metadata (there is no + * metadata-carrying tag write upstream): its stamp is already covered by the + * combined PATCH that always runs in this handler. `created_*` are never sent, + * so merge-patch keeps them untouched. A tag-write failure after a successful + * PATCH leaves a half-applied edit (text + stamp landed, tags not) — retryable, + * nothing lost. * * Contract: * - body missing text or tagIds, or carrying the retired `tags` key @@ -673,6 +808,7 @@ export async function handleUpdatePrompt( body, log, classifyPromptType, + callerId, ) { // `semrushPromptId` is validated as non-empty at the controller boundary // (serenity.js:259) before this handler is invoked over HTTP, so no @@ -725,7 +861,14 @@ export async function handleUpdatePrompt( }); try { - await transport.renamePrompt(semrushWorkspaceId, projectId, semrushPromptId, nextText); + // Combined v3 write: sets the text (`name`) and stamps the `updated_*` + // metadata pair in one request (replaces the v2 `rename`). Same refusal + // contract as rename — 404 (unknown id) → promptNotFound, 409 (text + // collides with a sibling) → thrown for the controller's `conflict` mapping. + await transport.patchPrompt(semrushWorkspaceId, projectId, semrushPromptId, { + name: nextText, + metadata: buildUpdateMetadata(callerId), + }); } catch (e) { if (isUpstreamGone(e)) { return { @@ -751,10 +894,11 @@ export async function handleUpdatePrompt( { id: semrushPromptId, references: typed.tagIds, replace: true }, ]); } catch (e) { - // The rename above already landed: the prompt's text has moved while its - // tags are stale. Record the partial mutation before propagating, so the - // generic upstream error the caller sees is attributable on-call. - log?.warn?.('updatePromptTagsByIds failed after a successful rename — text updated, tags stale', { + // The combined PATCH above already landed: the prompt's text + stamp have + // moved while its tags are stale. Record the partial mutation before + // propagating, so the generic upstream error the caller sees is + // attributable on-call. + log?.warn?.('updatePromptTagsByIds failed after a successful text/metadata PATCH — text updated, tags stale', { semrushPromptId, projectId, error: e.message, }); throw e; diff --git a/src/support/serenity/rest-transport.js b/src/support/serenity/rest-transport.js index 6016b1af61..4730775684 100644 --- a/src/support/serenity/rest-transport.js +++ b/src/support/serenity/rest-transport.js @@ -39,6 +39,29 @@ import { ErrorWithStatusCode } from '../utils.js'; // ceiling that still gives the user a clean error rather than a Lambda timeout. const DEFAULT_TIMEOUT_MS = 15_000; +/** + * The Project Engine facade EXTENDED with the v3 prompt-authorship-metadata + * write surface (LLMO-6289). WP2 re-vendors the client so the generated + * `SerenityProjectEngineTransport` carries these operations natively; until that + * release lands (this WP's merge gate — the dependency is bumped to the + * WP2-released version before merge), they are declared here as a local + * intersection so the single transport boundary type-checks under `// @ts-check` + * against the currently-installed client. Method names/shapes track the routes + * the revised ADR pins (spec §13 item 1): + * - createPromptsWithMetadata → POST /v3 prompts { items:[{name,metadata}], tag_ids } + * - patchPrompt → PATCH /v3 prompts/{id} { name?, metadata? } + * - patchPromptMetadata → PATCH /v3 prompts/{id}/metadata { } + * - patchPromptsMetadataBatch → PATCH /v3 prompts/metadata { items:[{id,metadata}] } + * WP2's generated types supersede this typedef verbatim on the dep bump. + * + * @typedef {ReturnType & { + * createPromptsWithMetadata: (init: any) => Promise, + * patchPrompt: (init: any) => Promise, + * patchPromptMetadata: (init: any) => Promise, + * patchPromptsMetadataBatch: (init: any) => Promise, + * }} ProjectEngineTransportWithMetadata + */ + /** * Error thrown when the Semrush upstream returns a non-2xx response or refuses * the auth header. `status` carries the upstream status; `body` is the parsed @@ -293,7 +316,9 @@ export function createSerenityTransport({ env, imsToken }) { // client's internal.js (library defaults: maxRetries 2, retryBaseDelayMs 200; // per-attempt timeout via the injected `createTimeoutFetch`, since the retry // layer wraps it and calls it once per attempt). - const projects = createSerenityProjectEngineTransport(projectEngineOptions); + const projects = /** @type {ProjectEngineTransportWithMetadata} */ ( + createSerenityProjectEngineTransport(projectEngineOptions) + ); // Raw Project Engine client, ONLY for GET /v1/workspaces/{id}/brand-topics: the // facade does not expose a brand-topics method yet (it is in-spec — the future @@ -332,21 +357,35 @@ export function createSerenityTransport({ env, imsToken }) { * Migration-verification "did it land?" checks must publish first (or accept * that an unpublished draft reads as 0 prompts), never treat empty as missing. * - * Note: Semrush rejects `sort_field` / `sort_dir` on this endpoint (see - * commit history on the prior `serenity` handler). Body is restricted to - * the fields the upstream documents as accepted. + * METADATA (LLMO-6289): WP0 added an Adobe-owned `metadata` column carried + * INLINE on each item of this read (`AIOPromptWithStatus.metadata`), and made + * this the sortable list path — the legacy "Semrush rejects sort_field / + * sort_dir here" note no longer holds for `sort` / `order`. `sort` is + * allow-listed to `metadata.created_at` / `metadata.updated_at` by the + * caller (handlers/prompts.js `resolveSort`) BEFORE it reaches here. Both are + * sent ONLY when a sort is requested, so an unsorted read is byte-for-byte the + * legacy call. Every other field stays restricted to what upstream documents. */ async listPromptsByTags(semrushWorkspaceId, projectId, body) { + /** @type {Record} */ + const requestBody = { + tag_ids: body?.tag_ids ?? [], + page: body?.page ?? 1, + limit: body?.limit ?? 200, + search: body?.search, + unassigned: body?.unassigned, + }; + if (body?.sort) { + requestBody.sort = body.sort; + requestBody.order = body.order; + } return projects.listPromptsByTagIds( { params: { path: { id: semrushWorkspaceId, project_id: projectId } }, - body: { - tag_ids: body?.tag_ids ?? [], - page: body?.page ?? 1, - limit: body?.limit ?? 200, - search: body?.search, - unassigned: body?.unassigned, - }, + // WP2's regenerated by_tags body carries sort/order + returns metadata; + // cast at this one boundary until the dep bump (see the file-level + // ProjectEngineTransportWithMetadata typedef). + body: /** @type {any} */ (requestBody), }, ); }, @@ -385,6 +424,101 @@ export function createSerenityTransport({ env, imsToken }) { ); }, + /** + * POST /v3/.../aio/prompts — the metadata-carrying create (LLMO-6289). Same + * ONE-shared-`tag_ids` model as {@link createPromptsByIds}, but each item is + * `{ name, metadata }` so the four authorship keys are stamped on the SAME + * write that creates the prompt (no read-before-write, nothing to sequence). + * The `metadata` object is built by the shared `buildCreateMetadata` helper + * (all four keys, `created_* = updated_*`). Response shape mirrors the v2 + * create's paginated list wrapper (`{ items: [{ id, name }], ... }`). ATOMIC + * on an unresolvable tag id, exactly like the v2 create. + * + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @param {Array<{ name: string, metadata: object }>} items - texts + per-item metadata. + * @param {string[]} tagIds - upstream tag ids attached to EVERY item. + */ + async createPromptsWithMetadata(semrushWorkspaceId, projectId, items, tagIds) { + return projects.createPromptsWithMetadata( + { + params: { path: { id: semrushWorkspaceId, project_id: projectId } }, + body: { items, tag_ids: tagIds }, + }, + ); + }, + + /** + * PATCH /v3/.../aio/prompts/{prompt_id} — combined in-place edit of a prompt's + * `name` (its text) AND its `metadata`, in ONE request (LLMO-6289). This is + * the text-edit write on the in-place edit path: it replaces the v2 `rename` + * so the `updated_*` metadata stamp rides the same mutation. Merge-patch + * semantics on `metadata` (RFC 7396): a key absent from the body is KEPT (so + * `created_*` are never touched by an edit), a string SETS, `null` DELETES. + * Preserves the prompt id. Same refusal contract as `rename` — 404 for an + * unknown id, 409 when `name` collides with a sibling prompt's exact text. + * + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @param {string} promptId - upstream prompt id to edit. + * @param {{ name?: string, metadata?: object }} body - next text and/or metadata merge. + */ + async patchPrompt(semrushWorkspaceId, projectId, promptId, body) { + return projects.patchPrompt( + { + params: { + path: { id: semrushWorkspaceId, project_id: projectId, prompt_id: promptId }, + }, + body, + }, + ); + }, + + /** + * PATCH /v3/.../aio/prompts/{prompt_id}/metadata — metadata-only merge-patch + * of ONE prompt (LLMO-6289, RFC 7396). Touches only the keys the body carries + * (absent = keep, string = set, null = delete). The prompt's text and tags are + * untouched. Used to stamp authorship without a text edit. + * + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @param {string} promptId - upstream prompt id. + * @param {object} metadata - the merge-patch body. + */ + async patchPromptMetadata(semrushWorkspaceId, projectId, promptId, metadata) { + return projects.patchPromptMetadata( + { + params: { + path: { id: semrushWorkspaceId, project_id: projectId, prompt_id: promptId }, + }, + body: metadata, + }, + ); + }, + + /** + * PATCH /v3/.../aio/prompts/metadata — BATCH metadata merge-patch across many + * prompts in ONE upstream transaction (LLMO-6289). Body: `{ items: [{ id, + * metadata }] }`, each `metadata` an independent RFC 7396 merge. The batch is + * ATOMIC upstream: a CHECK violation on ANY item (e.g. a `created_by` / + * `updated_by` longer than 100 chars) rolls the WHOLE batch back and answers + * 400 — so callers must isolate a deterministic offender rather than retry the + * batch whole. Nothing else in this WP calls it; it exposes the batch write + * surface the ADR pins for bulk stampers. + * + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @param {Array<{ id: string, metadata: object }>} items + */ + async patchPromptsMetadataBatch(semrushWorkspaceId, projectId, items) { + return projects.patchPromptsMetadataBatch( + { + params: { path: { id: semrushWorkspaceId, project_id: projectId } }, + body: { items }, + }, + ); + }, + /** * DELETE /v2/.../aio/prompts — deletes prompts by their Semrush ids in * this project. Body shape: { ids: [...] }. diff --git a/test/controllers/serenity.test.js b/test/controllers/serenity.test.js index 40f86d761c..e61b038dad 100644 --- a/test/controllers/serenity.test.js +++ b/test/controllers/serenity.test.js @@ -1370,6 +1370,9 @@ describe('SerenityController', () => { dataAccess: { BrandSemrushProject: ctx.dataAccess.BrandSemrushProject }, // Dynamic-allocation kill-switch defaults OFF (env unset) — the guard is a no-op. dynamicAllocation: false, + // Caller identity for the created_* stamp (LLMO-6289); the test context + // has no auth profile, so it resolves to the `unknown` sentinel. + callerId: 'unknown', }); // The org parent (JIT units pool) is threaded POSITIONALLY (arg index 2), not in the // options bag — the same id given to ensureSubworkspace. @@ -1983,6 +1986,9 @@ describe('SerenityController', () => { competitors: [], dataAccess: { BrandSemrushProject: ctx.dataAccess.BrandSemrushProject }, dynamicAllocation: false, + // Caller id resolved once for the batch; no auth profile in the test + // context → the `unknown` sentinel (LLMO-6289). + callerId: 'unknown', }; const { firstCall, secondCall } = handlers.handleCreateMarketSubworkspace; expect(firstCall.args[7]).to.deep.equal(expectedOpts); diff --git a/test/openapi-contract/serenity-api.test.js b/test/openapi-contract/serenity-api.test.js index 05da5012ce..2b57b24657 100644 --- a/test/openapi-contract/serenity-api.test.js +++ b/test/openapi-contract/serenity-api.test.js @@ -109,12 +109,19 @@ const FIXTURES = { languageCode: 'en', text: 'sample', tagMap: { 'topic-a': 't-1' }, + // Authorship metadata fields (LLMO-6289) on a list item. + createdAt: '2026-07-01T00:00:00Z', + createdBy: 'user-a', + updatedAt: '2026-07-02T00:00:00Z', + updatedBy: 'unknown', }], total: 1, page: 1, limit: 50, }, - query: { geoTargetId: '2840', languageCode: 'en', tagIds: ['t-1'] }, + query: { + geoTargetId: '2840', languageCode: 'en', tagIds: ['t-1'], sort: 'metadata.updated_at', order: 'desc', + }, }, createSerenityPrompts: { expectedStatus: 200, diff --git a/test/support/serenity/brand-provisioning.test.js b/test/support/serenity/brand-provisioning.test.js index af9c9e2b25..99e895b576 100644 --- a/test/support/serenity/brand-provisioning.test.js +++ b/test/support/serenity/brand-provisioning.test.js @@ -197,6 +197,9 @@ describe('provisionBrandSubworkspace', () => { brandUrlSources: null, competitors: [], publishMode: 'require', + // Caller identity for the created_* stamp (LLMO-6289); the test context + // has no auth profile → the `unknown` sentinel. + callerId: 'unknown', }); // The stub drives the sub-workspace title off the brand's name + id. expect(brandStub.getName()).to.equal('Acme'); diff --git a/test/support/serenity/dynamic-allocation-fronting.test.js b/test/support/serenity/dynamic-allocation-fronting.test.js index d0cacacd78..27c046cc41 100644 --- a/test/support/serenity/dynamic-allocation-fronting.test.js +++ b/test/support/serenity/dynamic-allocation-fronting.test.js @@ -82,7 +82,7 @@ function makeTransport(overrides = {}) { getWorkspaceStatus: sinon.stub().resolves({ status: 'created' }), getWorkspaceResources, listPromptsByTags: sinon.stub().resolves({ items: [] }), - createPromptsByIds: sinon.stub().resolves({ items: [{ id: 'prompt-1' }] }), + createPromptsWithMetadata: sinon.stub().resolves({ items: [{ id: 'prompt-1' }] }), listAiModels: sinon.stub().resolves({ items: [] }), listGlobalAiModels: sinon.stub().resolves({ items: [] }), addAiModel: sinon.stub().resolves(null), @@ -184,6 +184,7 @@ describe('dynamic-allocation fronting — create-prompts', () => { }, log, undefined, // classifyPromptType (tag-dimension path — not under test here) + undefined, // callerId (LLMO-6289) { dynamicAllocation: true, parentWorkspaceId: MASTER }, ); expect(t.getWorkspaceResources).to.have.been.calledWith(WS); @@ -202,6 +203,7 @@ describe('dynamic-allocation fronting — create-prompts', () => { }, log, undefined, // classifyPromptType + undefined, // callerId (LLMO-6289) { dynamicAllocation: false, parentWorkspaceId: MASTER }, ); expect(t.getWorkspaceResources).to.not.have.been.called; @@ -357,6 +359,7 @@ describe('dynamic-allocation — enforcement choke point', () => { }, log, undefined, // classifyPromptType + undefined, // callerId (LLMO-6289) { dynamicAllocation: true, parentWorkspaceId: MASTER }, ), }, @@ -541,6 +544,7 @@ describe('dynamic-allocation fronting — retryOnQuota wiring', () => { }, log, undefined, + undefined, // callerId (LLMO-6289) { dynamicAllocation: true, parentWorkspaceId: MASTER }, ); // The retry succeeded, so the create is NOT recorded as a publish failure. @@ -595,9 +599,9 @@ describe('dynamic-allocation fronting — retryOnQuota wiring', () => { }); // LLMO-6190 follow-up (live-verified ~9s Semrush gateway write-enforcement lag): the three - // metered WRITE call sites below (createProject, createPromptsByIds, createOnePrompt) are now - // also fronted by `headroom.retryOnQuota`, not just publish. These tests prove the wrapping is - // wired at each site — the poll-retry's own timing/backoff/deadline mechanics are unit-tested + // metered WRITE call sites below (createProject, createPromptsWithMetadata, createOnePrompt) are + // now also fronted by `headroom.retryOnQuota`, not just publish. These tests prove the wrapping + // is wired at each site — the poll-retry's own timing/backoff/deadline mechanics are unit-tested // with injectable fake timers in dynamic-allocation-active.test.js; every case here resolves on // the FIRST poll attempt (no real sleep triggered) so the suite stays fast. @@ -623,13 +627,13 @@ describe('dynamic-allocation fronting — retryOnQuota wiring', () => { expect(t.createProject).to.have.been.calledTwice; }); - it('create-market with generateTopics: createPromptsByIds 405s once then a bounded top-up+retry succeeds', async () => { - const createPromptsByIds = sinon.stub(); - createPromptsByIds.onFirstCall().rejects(quota405()); - createPromptsByIds.onSecondCall().resolves({ items: [{ id: 'prompt-1' }] }); + it('create-market with generateTopics: createPromptsWithMetadata 405s once then a bounded top-up+retry succeeds', async () => { + const createPromptsWithMetadata = sinon.stub(); + createPromptsWithMetadata.onFirstCall().rejects(quota405()); + createPromptsWithMetadata.onSecondCall().resolves({ items: [{ id: 'prompt-1' }] }); const t = makeTransport({ listProjects: sinon.stub().resolves({ items: [] }), - createPromptsByIds, + createPromptsWithMetadata, getBrandTopics: sinon.stub().resolves({ items: [{ topic: 't1', volume: 10, prompts: ['what is Acme?'] }], }), @@ -650,7 +654,7 @@ describe('dynamic-allocation fronting — retryOnQuota wiring', () => { }, ); expect(res.status).to.equal(201); - expect(t.createPromptsByIds).to.have.been.calledTwice; + expect(t.createPromptsWithMetadata).to.have.been.calledTwice; }); it('create-prompts, concurrent batch: each item independently recovers from its own 405 — per-item-keyed stub, not a flat call-index', async () => { @@ -659,8 +663,8 @@ describe('dynamic-allocation fronting — retryOnQuota wiring', () => { // overall and skip its own retry path). Keying on identity guarantees EVERY item's first // attempt 405s once and its second succeeds, regardless of interleaving. const attemptsByText = new Map(); - const createPromptsByIds = sinon.stub().callsFake(async (wsId, projectId, texts) => { - const key = texts[0]; + const createPromptsWithMetadata = sinon.stub().callsFake(async (wsId, projectId, items) => { + const key = items[0].name; const attempt = (attemptsByText.get(key) ?? 0) + 1; attemptsByText.set(key, attempt); if (attempt === 1) { @@ -670,7 +674,7 @@ describe('dynamic-allocation fronting — retryOnQuota wiring', () => { }); const t = makeTransport({ listProjects: sinon.stub().resolves({ items: [proj()] }), - createPromptsByIds, + createPromptsWithMetadata, }); const result = await handleCreatePromptsSubworkspace( t, @@ -690,6 +694,7 @@ describe('dynamic-allocation fronting — retryOnQuota wiring', () => { }, log, undefined, + undefined, // callerId (LLMO-6289) { dynamicAllocation: true, parentWorkspaceId: MASTER }, ); expect(result.failed).to.deep.equal([]); @@ -702,8 +707,8 @@ describe('dynamic-allocation fronting — retryOnQuota wiring', () => { // Deterministic per-key state — 'recovers' 405s once then succeeds, 'fails-hard' always throws // a non-quota error that must never be retried. let recoversAttempt = 0; - const stub = sinon.stub().callsFake(async (wsId, projectId, texts) => { - const [text] = texts; + const stub = sinon.stub().callsFake(async (wsId, projectId, items) => { + const text = items[0].name; if (text === 'recovers') { recoversAttempt += 1; if (recoversAttempt === 1) { @@ -715,7 +720,7 @@ describe('dynamic-allocation fronting — retryOnQuota wiring', () => { }); const t = makeTransport({ listProjects: sinon.stub().resolves({ items: [proj()] }), - createPromptsByIds: stub, + createPromptsWithMetadata: stub, }); const result = await handleCreatePromptsSubworkspace( t, @@ -732,6 +737,7 @@ describe('dynamic-allocation fronting — retryOnQuota wiring', () => { }, log, undefined, + undefined, // callerId (LLMO-6289) { dynamicAllocation: true, parentWorkspaceId: MASTER }, ); expect(result.created).to.have.lengthOf(1); diff --git a/test/support/serenity/handlers/markets-subworkspace.test.js b/test/support/serenity/handlers/markets-subworkspace.test.js index a6bf85ba35..fedc06f31f 100644 --- a/test/support/serenity/handlers/markets-subworkspace.test.js +++ b/test/support/serenity/handlers/markets-subworkspace.test.js @@ -37,6 +37,22 @@ use(sinonChai); // intent=Informational); the third tag is the per-prompt computed `type`. const STANDARD_IDS = [TAG_IDS.originAi, TAG_IDS.intentInformational]; +// Matches one v3 create item `{ name, metadata }` (LLMO-6289). These handlers +// default callerId to the `unknown` sentinel when a create omits it, so an +// AI-generated prompt is stamped `created_by: unknown` unless a caller threads +// a real id (the controller does). +const genItemMatch = (name, by = 'unknown') => sinon.match({ + name, + metadata: sinon.match({ + created_at: sinon.match.string, + created_by: by, + updated_at: sinon.match.string, + updated_by: by, + }), +}); +// Extracts prompt texts from a createPromptsWithMetadata call's items arg. +const itemNames = (items) => items.map((i) => i.name); + const BRAND = 'brand-1'; const WS = 'subworkspace-ws-1'; const PARENT = 'parent-ws'; @@ -71,7 +87,7 @@ function makeTransport(overrides = {}) { deleteAiModelsByIds: sinon.stub().resolves(null), createProjectTags: sinon.stub().resolves([]), listProjectTags: makeListProjectTagsStub(), - createPromptsByIds: sinon.stub().resolves({ page: 1, total: 0, items: [] }), + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 0, items: [] }), listBenchmarks: sinon.stub().resolves({ aio_benchmarks: [{ id: 'bench-1', main_brand: true }] }), createBrandUrls: sinon.stub().resolves({ ids: [], existing_count: 0 }), createBenchmarks: sinon.stub().resolves({ ids: ['bm-new'], existing_count: 0 }), @@ -606,10 +622,10 @@ describe('markets-subworkspace handlers', () => { // (needle 'trail'): the shared classifier matches on WORD boundaries, so // 'top trail shoes' is branded and 'best running shoes' is not. Prompts are // grouped by computed type, one upstream call per group, because - // createPromptsByIds carries ONE shared tag_ids array per call. - expect(transport.createPromptsByIds).to.have.been.calledTwice; - expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['best running shoes'], [...STANDARD_IDS, TAG_IDS.typeNonBranded]); - expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['top trail shoes'], [...STANDARD_IDS, TAG_IDS.typeBranded]); + // createPromptsWithMetadata carries ONE shared tag_ids array per call. + expect(transport.createPromptsWithMetadata).to.have.been.calledTwice; + expect(transport.createPromptsWithMetadata).to.have.been.calledWithExactly(WS, 'new-proj', [genItemMatch('best running shoes')], [...STANDARD_IDS, TAG_IDS.typeNonBranded]); + expect(transport.createPromptsWithMetadata).to.have.been.calledWithExactly(WS, 'new-proj', [genItemMatch('top trail shoes')], [...STANDARD_IDS, TAG_IDS.typeBranded]); expect(res.body).to.include({ topicCount: 1, promptCount: 2, published: true }); // Models are STAGED (no inner publish) — only the single final publish runs, // so a quota 405 can never escape mid-flow from the model-set commit. @@ -617,9 +633,9 @@ describe('markets-subworkspace handlers', () => { }); it('dynamic-allocation ON: fronts prompt headroom sized on the generated batch BEFORE the write (LLMO-6190, live-verified)', async () => { - // The metered write is createPromptsByIds itself (Rainer, live-verified) — a disguised-quota - // 405 fires there, before any publish. getWorkspaceResources must be read before the first - // createPromptsByIds call for the generated batch, not only before the final publish. + // The metered write is createPromptsWithMetadata itself (Rainer, live-verified) — a + // disguised-quota 405 fires there, before any publish. getWorkspaceResources must be read + // before the first create call for the generated batch, not only before the final publish. const transport = makeTransport({ getBrandTopics: sinon.stub().resolves([ { topic: 'Running Shoes', volume: 900, prompts: ['best running shoes', 'top trail shoes'] }, @@ -647,7 +663,7 @@ describe('markets-subworkspace handlers', () => { expect(res.status).to.equal(201); expect(transport.getWorkspaceResources).to.have.been.called; expect(transport.getWorkspaceResources.firstCall) - .to.have.been.calledBefore(transport.createPromptsByIds.firstCall); + .to.have.been.calledBefore(transport.createPromptsWithMetadata.firstCall); }); it('propagates a fatal model-attach failure (NOT best-effort like URL/competitor enrichment)', async () => { @@ -718,16 +734,16 @@ describe('markets-subworkspace handlers', () => { ); expect(res.status).to.equal(201); // Two branded prompts share one call; the single non-branded one gets its own. - expect(transport.createPromptsByIds).to.have.been.calledWithExactly( + expect(transport.createPromptsWithMetadata).to.have.been.calledWithExactly( WS, 'new-proj', - ['Best ACME running shoes', 'top trail sneakers from zoom'], + [genItemMatch('Best ACME running shoes'), genItemMatch('top trail sneakers from zoom')], [...STANDARD_IDS, TAG_IDS.typeBranded], ); - expect(transport.createPromptsByIds).to.have.been.calledWithExactly( + expect(transport.createPromptsWithMetadata).to.have.been.calledWithExactly( WS, 'new-proj', - ['most comfortable sandals'], + [genItemMatch('most comfortable sandals')], [...STANDARD_IDS, TAG_IDS.typeNonBranded], ); }); @@ -766,9 +782,9 @@ describe('markets-subworkspace handlers', () => { { generateTopics: true, publishMode: 'skip' }, ); expect(res.status).to.equal(201); - expect(transport.createPromptsByIds).to.have.been.calledOnce; - const [, , items] = transport.createPromptsByIds.firstCall.args; - expect(items).to.deep.equal(['best boots']); + expect(transport.createPromptsWithMetadata).to.have.been.calledOnce; + const [, , items] = transport.createPromptsWithMetadata.firstCall.args; + expect(itemNames(items)).to.deep.equal(['best boots']); }); it('skips the prompt attach (topicCount/promptCount 0) when topics yield no prompts', async () => { @@ -791,7 +807,7 @@ describe('markets-subworkspace handlers', () => { ); expect(res.status).to.equal(201); expect(res.body).to.include({ topicCount: 0, promptCount: 0 }); - expect(transport.createPromptsByIds).to.not.have.been.called; + expect(transport.createPromptsWithMetadata).to.not.have.been.called; }); it('409s when a LIVE project already exists for the slice', async () => { @@ -1411,8 +1427,8 @@ describe('markets-subworkspace — defensive branch coverage', () => { expect(res.status).to.equal(201); // Both prompts are attached (both non-branded, so one call) — no crash from // the sort comparator. - expect(transport.createPromptsByIds).to.have.been.calledOnce; - expect(transport.createPromptsByIds.firstCall.args[2]) + expect(transport.createPromptsWithMetadata).to.have.been.calledOnce; + expect(itemNames(transport.createPromptsWithMetadata.firstCall.args[2])) .to.deep.equal(['alpha prompt', 'beta prompt']); }); @@ -1445,7 +1461,7 @@ describe('markets-subworkspace — defensive branch coverage', () => { ); expect(res.status).to.equal(201); // 'adobe shoes' contains 'adobe' → branded (null alias was dropped, not used). - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'new-proj', ['adobe shoes'], [...STANDARD_IDS, TAG_IDS.typeBranded]); + expect(transport.createPromptsWithMetadata).to.have.been.calledOnceWithExactly(WS, 'new-proj', [genItemMatch('adobe shoes')], [...STANDARD_IDS, TAG_IDS.typeBranded]); }); // Line 115 truthy branch: body.name is provided and valid — String(body.name) is used @@ -1544,8 +1560,8 @@ describe('markets-subworkspace — defensive branch coverage', () => { expect(res.status).to.equal(201); // Needles = ['b','real'] (the '' element was coerced + filtered out). // 'real deal' contains 'real' → branded; 'plain text' → non-branded. - expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['real deal'], [...STANDARD_IDS, TAG_IDS.typeBranded]); - expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['plain text'], [...STANDARD_IDS, TAG_IDS.typeNonBranded]); + expect(transport.createPromptsWithMetadata).to.have.been.calledWithExactly(WS, 'new-proj', [genItemMatch('real deal')], [...STANDARD_IDS, TAG_IDS.typeBranded]); + expect(transport.createPromptsWithMetadata).to.have.been.calledWithExactly(WS, 'new-proj', [genItemMatch('plain text')], [...STANDARD_IDS, TAG_IDS.typeNonBranded]); }); // The two guards below both fire when the tag tree, freshly provisioned, still @@ -1556,7 +1572,7 @@ describe('markets-subworkspace — defensive branch coverage', () => { // that stays empty is exactly that sequence. // // Attaching a prompt to a guessed id is the failure these prevent: - // `createPromptsByIds` is ATOMIC on an unresolvable id — it 500s and writes + // `createPromptsWithMetadata` is ATOMIC on an unresolvable id — it 500s and writes // nothing — so the handler must fail before it builds the call, not after. it('generateAndAttachPrompts: 502s when the standard prompt tag ids cannot be resolved', async () => { // The four roots exist; no closed value under any of them does, and the @@ -1582,7 +1598,7 @@ describe('markets-subworkspace — defensive branch coverage', () => { expect(err.status).to.equal(502); expect(err.message).to.match(/did not persist the tag\(s\)/); // Nothing was attached — the seam fails before any prompt write is built. - expect(transport.createPromptsByIds).to.have.not.been.called; + expect(transport.createPromptsWithMetadata).to.have.not.been.called; }); it('generateAndAttachPrompts: 502s when the type vocabulary cannot be provisioned', async () => { @@ -1610,6 +1626,6 @@ describe('markets-subworkspace — defensive branch coverage', () => { expect(err.status).to.equal(502); expect(err.message).to.match(/did not persist the tag\(s\): non-branded/); - expect(transport.createPromptsByIds).to.have.not.been.called; + expect(transport.createPromptsWithMetadata).to.have.not.been.called; }); }); diff --git a/test/support/serenity/handlers/prompts-subworkspace.test.js b/test/support/serenity/handlers/prompts-subworkspace.test.js index 01c4d591a7..0cc829192f 100644 --- a/test/support/serenity/handlers/prompts-subworkspace.test.js +++ b/test/support/serenity/handlers/prompts-subworkspace.test.js @@ -46,13 +46,16 @@ function makeTransport(overrides = {}) { listProjects: sinon.stub().resolves({ items: [proj()] }), listProjectTags: makeListProjectTagsStub(), listPromptsByTags: sinon.stub().resolves({ items: [] }), - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-prompt', name: 'p' }], }), deletePromptsByIds: sinon.stub().resolves(null), - renamePrompt: sinon.stub().callsFake( - (ws, pid, promptId, newName) => Promise.resolve( - { id: promptId, name: newName, is_updated: true }, + // Production passes the combined v3 body `{ name, metadata }` as the 4th arg + // (not a bare string), so the stub reads `body.name` — matching the real + // signature, not a shape that would mask a mismatch. + patchPrompt: sinon.stub().callsFake( + (ws, pid, promptId, body) => Promise.resolve( + { id: promptId, name: body?.name ?? '', is_updated: true }, ), ), updatePromptTagsByIds: sinon.stub().resolves(null), @@ -64,6 +67,22 @@ function makeTransport(overrides = {}) { // A classifier over BARE `type` values, matching the flat-mode twin. const classifyByBrandMention = (text) => (/\bacme\b/i.test(text) ? 'branded' : 'non-branded'); +// Matchers for the v3 metadata write shapes (LLMO-6289), mirroring the flat-mode +// twin's test helpers. `by` is undefined when a test omits callerId. +const createItemMatch = (name, by) => sinon.match({ + name, + metadata: sinon.match({ + created_at: sinon.match.string, + created_by: by, + updated_at: sinon.match.string, + updated_by: by, + }), +}); +const patchTextMatch = (name, by) => sinon.match({ + name, + metadata: sinon.match({ updated_at: sinon.match.string, updated_by: by }), +}); + describe('prompts-subworkspace handlers', () => { afterEach(() => sinon.restore()); @@ -103,6 +122,10 @@ describe('prompts-subworkspace handlers', () => { path: [{ id: TAG_IDS.categoryRoot, name: 'category' }], }], tagMap: { 'Running Shoes': TAG_IDS.categoryRunningShoes }, + createdAt: null, + createdBy: null, + updatedAt: null, + updatedBy: null, }]); expect(result).to.include({ total: 1, page: 1, limit: 50 }); expect(transport.listPromptsByTags).to.have.been.calledWith(WS, 'p-us-en'); @@ -126,6 +149,74 @@ describe('prompts-subworkspace handlers', () => { .to.be.rejectedWith(/geoTargetId/); }); + // sort/order forwarding (LLMO-6289) — the subworkspace twin resolves its + // project via the live listing (not the DB), so its forwarding path is + // independently testable from the flat-mode twin in prompts.test.js. + it('maps item.metadata to the DTO authorship fields', async () => { + const transport = makeTransport({ + listPromptsByTags: sinon.stub().resolves({ + items: [{ + id: 'q1', + name: 'a prompt', + metadata: { + created_at: '2026-07-01T00:00:00Z', + created_by: 'user-a', + updated_at: '2026-07-02T00:00:00Z', + updated_by: 'user-b', + }, + }], + total: 1, + }), + }); + const result = await handleListPromptsSubworkspace(transport, WS, { geoTargetId: 2840, languageCode: 'en' }, log); + expect(result.items[0]).to.include({ + createdAt: '2026-07-01T00:00:00Z', + createdBy: 'user-a', + updatedAt: '2026-07-02T00:00:00Z', + updatedBy: 'user-b', + }); + }); + + it('forwards a valid sort/order to the transport list call', async () => { + const transport = makeTransport({ + listPromptsByTags: sinon.stub().resolves({ items: [], total: 0 }), + }); + await handleListPromptsSubworkspace(transport, WS, { + geoTargetId: 2840, languageCode: 'en', sort: 'metadata.updated_at', order: 'asc', + }, log); + const [, , body] = transport.listPromptsByTags.firstCall.args; + expect(body.sort).to.equal('metadata.updated_at'); + expect(body.order).to.equal('asc'); + }); + + it('400s a sort field outside the allow-list before any upstream call', async () => { + const transport = makeTransport({ listPromptsByTags: sinon.stub() }); + await expect(handleListPromptsSubworkspace(transport, WS, { + geoTargetId: 2840, languageCode: 'en', sort: 'text', + }, log)).to.be.rejectedWith(/sort must be one of/); + expect(transport.listPromptsByTags).to.not.have.been.called; + }); + + it('400s an invalid order before any upstream call', async () => { + const transport = makeTransport({ listPromptsByTags: sinon.stub() }); + await expect(handleListPromptsSubworkspace(transport, WS, { + geoTargetId: 2840, languageCode: 'en', sort: 'metadata.created_at', order: 'sideways', + }, log)).to.be.rejectedWith(/order must be one of/); + expect(transport.listPromptsByTags).to.not.have.been.called; + }); + + it('forwards neither sort nor order on an unsorted read', async () => { + const transport = makeTransport({ + listPromptsByTags: sinon.stub().resolves({ items: [], total: 0 }), + }); + await handleListPromptsSubworkspace(transport, WS, { + geoTargetId: 2840, languageCode: 'en', + }, log); + const [, , body] = transport.listPromptsByTags.firstCall.args; + expect(body.sort).to.equal(undefined); + expect(body.order).to.equal(undefined); + }); + it('uses the upstream total when a full page is returned', async () => { const transport = makeTransport({ listPromptsByTags: sinon.stub().resolves({ @@ -154,14 +245,24 @@ describe('prompts-subworkspace handlers', () => { }, log); expect(result.created).to.have.length(1); expect(result.created[0]).to.include({ semrushPromptId: 'new-prompt', geoTargetId: 2840 }); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'p-us-en', ['p'], ['tag-1']); + expect(transport.createPromptsWithMetadata).to.have.been.calledOnceWithExactly(WS, 'p-us-en', [createItemMatch('p', undefined)], ['tag-1']); expect(transport.publishProject).to.have.been.calledOnceWith(WS, 'p-us-en'); }); + it('stamps a REAL resolved callerId as created_* (full stamping path, not the undefined default)', async () => { + const transport = makeTransport(); + await handleCreatePromptsSubworkspace(transport, WS, { + prompts: [{ + text: 'p', tagIds: ['tag-1'], geoTargetId: 2840, languageCode: 'en', + }], + }, log, undefined, 'caller-42'); + expect(transport.createPromptsWithMetadata).to.have.been.calledOnceWithExactly(WS, 'p-us-en', [createItemMatch('p', 'caller-42')], ['tag-1']); + }); + it('dynamic-allocation ON: fronts headroom sized on the batch BEFORE the write, not just before publish (LLMO-6190, live-verified)', async () => { - // The metered write is createPromptsByIds itself (Rainer, live-verified) — a disguised-quota - // 405 fires there, before any publish. getWorkspaceResources must be read (and, if it were - // needed, a top-up transferred) before the first createPromptsByIds call, not after. + // The metered write is createPromptsWithMetadata itself (Rainer, live-verified) — a + // disguised-quota 405 fires there, before any publish. getWorkspaceResources must be read (if + // needed, a top-up transferred) before the first createPromptsWithMetadata call, not after. const transport = makeTransport({ getWorkspaceResources: sinon.stub().resolves({ product_resources: { @@ -177,11 +278,11 @@ describe('prompts-subworkspace handlers', () => { prompts: [{ text: 'p', tagIds: ['tag-1'], geoTargetId: 2840, languageCode: 'en', }], - }, log, undefined, { dynamicAllocation: true, parentWorkspaceId: 'parent-ws' }); + }, log, undefined, undefined, { dynamicAllocation: true, parentWorkspaceId: 'parent-ws' }); expect(result.created).to.have.length(1); expect(transport.getWorkspaceResources).to.have.been.calledOnceWith(WS); expect(transport.getWorkspaceResources) - .to.have.been.calledBefore(transport.createPromptsByIds); + .to.have.been.calledBefore(transport.createPromptsWithMetadata); }); // A tag NAME cannot address a nested tag, so a `tags` key is rejected @@ -196,7 +297,7 @@ describe('prompts-subworkspace handlers', () => { }, log); expect(result.created).to.have.length(0); expect(result.skipped).to.have.length(1); - expect(transport.createPromptsByIds).to.not.have.been.called; + expect(transport.createPromptsWithMetadata).to.not.have.been.called; }); it('injects the computed type tag id from the classifier (serenity-docs#31, twin of the flat-mode layer)', async () => { @@ -212,10 +313,10 @@ describe('prompts-subworkspace handlers', () => { expect(result.created[0].tagIds).to.deep.equal([ TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, ]); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly( + expect(transport.createPromptsWithMetadata).to.have.been.calledOnceWithExactly( WS, 'p-us-en', - ['is Acme good?'], + [createItemMatch('is Acme good?', undefined)], [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded], ); }); @@ -230,7 +331,7 @@ describe('prompts-subworkspace handlers', () => { expect(result.created).to.have.length(0); expect(result.skipped).to.have.length(1); expect(transport.listProjects).to.have.been.calledOnce; - expect(transport.createPromptsByIds).to.not.have.been.called; + expect(transport.createPromptsWithMetadata).to.not.have.been.called; }); it('400s on an empty prompts array', async () => { @@ -260,7 +361,7 @@ describe('prompts-subworkspace handlers', () => { // the per-item failed.message must be redacted, never echoed to the client. const leak = 'Semrush POST https://gw.internal/workspaces/ws/projects/p/prompts failed: 500'; const transport = makeTransport({ - createPromptsByIds: sinon.stub().rejects(new SerenityTransportError(500, leak)), + createPromptsWithMetadata: sinon.stub().rejects(new SerenityTransportError(500, leak)), }); const result = await handleCreatePromptsSubworkspace(transport, WS, { prompts: [{ @@ -276,7 +377,7 @@ describe('prompts-subworkspace handlers', () => { it('defaults a statusless create failure to status 500', async () => { const transport = makeTransport({ - createPromptsByIds: sinon.stub().rejects(new Error('no status')), + createPromptsWithMetadata: sinon.stub().rejects(new Error('no status')), }); const result = await handleCreatePromptsSubworkspace(transport, WS, { prompts: [{ @@ -310,17 +411,32 @@ describe('prompts-subworkspace handlers', () => { expect(result.status).to.equal(200); // The id is preserved — the edit is in place, never a re-create. expect(result.body.semrushPromptId).to.equal('old-id'); - expect(transport.renamePrompt).to.have.been.calledOnceWithExactly(WS, 'p-us-en', 'old-id', 'new'); + expect(transport.patchPrompt).to.have.been.calledOnceWithExactly(WS, 'p-us-en', 'old-id', patchTextMatch('new', undefined)); expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly( WS, 'p-us-en', [{ id: 'old-id', references: ['tag-1'], replace: true }], ); expect(transport.deletePromptsByIds).to.not.have.been.called; - expect(transport.createPromptsByIds).to.not.have.been.called; + expect(transport.createPromptsWithMetadata).to.not.have.been.called; expect(transport.publishProject).to.have.been.calledOnce; }); + it('stamps a REAL resolved callerId as updated_* via the combined PATCH (created_* untouched)', async () => { + const transport = makeTransport(); + const result = await handleUpdatePromptSubworkspace(transport, WS, 'old-id', { + text: 'new', tagIds: ['tag-1'], geoTargetId: 2840, languageCode: 'en', + }, log, undefined, 'caller-42'); + expect(result.status).to.equal(200); + expect(transport.patchPrompt).to.have.been.calledOnceWithExactly(WS, 'p-us-en', 'old-id', patchTextMatch('new', 'caller-42')); + // The combined PATCH carries NO created_* (merge-patch keeps them); the tag + // PUT carries references only, no metadata. + const body = transport.patchPrompt.firstCall.args[3]; + expect(body.metadata).to.not.have.property('created_at'); + expect(body.metadata).to.not.have.property('created_by'); + expect(transport.updatePromptTagsByIds.firstCall.args[2][0]).to.not.have.property('metadata'); + }); + it('404s marketNotFound when the slice has no project', async () => { const transport = makeTransport({ listProjects: sinon.stub().resolves({ items: [] }) }); const result = await handleUpdatePromptSubworkspace(transport, WS, 'old-id', { @@ -332,7 +448,7 @@ describe('prompts-subworkspace handlers', () => { it('404s promptNotFound when the upstream rename 404s (no tag write)', async () => { const transport = makeTransport({ - renamePrompt: sinon.stub().rejects(new SerenityTransportError(404, 'gone')), + patchPrompt: sinon.stub().rejects(new SerenityTransportError(404, 'gone')), }); const result = await handleUpdatePromptSubworkspace(transport, WS, 'old-id', { text: 'new', tagIds: ['tag-1'], geoTargetId: 2840, languageCode: 'en', @@ -375,7 +491,7 @@ describe('prompts-subworkspace handlers', () => { expect(result.status).to.equal(200); expect(result.body.semrushPromptId).to.equal('old-id'); expect(result.body.tagIds).to.deep.equal(['tag-cat-1']); - expect(transport.renamePrompt).to.have.been.calledOnceWithExactly(WS, 'p-us-en', 'old-id', 'new'); + expect(transport.patchPrompt).to.have.been.calledOnceWithExactly(WS, 'p-us-en', 'old-id', patchTextMatch('new', undefined)); expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly( WS, 'p-us-en', @@ -425,7 +541,7 @@ describe('prompts-subworkspace handlers', () => { it('re-throws a rename 409 (text collision) with no tag write and no publish', async () => { const transport = makeTransport({ - renamePrompt: sinon.stub().rejects(new SerenityTransportError(409, 'conflict')), + patchPrompt: sinon.stub().rejects(new SerenityTransportError(409, 'conflict')), }); await expect(handleUpdatePromptSubworkspace(transport, WS, 'old-id', { text: 'a sibling\'s text', tagIds: ['tag-1'], geoTargetId: 2840, languageCode: 'en', @@ -436,7 +552,7 @@ describe('prompts-subworkspace handlers', () => { it('re-throws a non-404 rename failure (no tag write)', async () => { const transport = makeTransport({ - renamePrompt: sinon.stub().rejects(new SerenityTransportError(500, 'boom')), + patchPrompt: sinon.stub().rejects(new SerenityTransportError(500, 'boom')), }); await expect(handleUpdatePromptSubworkspace(transport, WS, 'old-id', { text: 'new', tagIds: ['tag-1'], geoTargetId: 2840, languageCode: 'en', @@ -453,10 +569,10 @@ describe('prompts-subworkspace handlers', () => { await expect(handleUpdatePromptSubworkspace(transport, WS, 'old-id', { text: 'new', tagIds: ['tag-1'], geoTargetId: 2840, languageCode: 'en', }, warnLog)).to.be.rejectedWith(/tag write boom/); - expect(transport.renamePrompt).to.have.been.calledOnce; + expect(transport.patchPrompt).to.have.been.calledOnce; expect(transport.publishProject).to.not.have.been.called; expect(warnLog.warn).to.have.been.calledOnceWith( - 'updatePromptTagsByIds failed after a successful rename — text updated, tags stale', + 'updatePromptTagsByIds failed after a successful text/metadata PATCH — text updated, tags stale', { semrushPromptId: 'old-id', projectId: 'p-us-en', error: 'tag write boom' }, ); }); @@ -657,11 +773,11 @@ describe('prompts-subworkspace — defensive branch coverage', () => { ).to.be.rejectedWith(/non-empty/); }); - // `createPromptsByIds` resolves without an `items` array → semrushPromptId + // `createPromptsWithMetadata` resolves without an `items` array → semrushPromptId // degrades to '' rather than the literal string "undefined". - it('handleCreatePromptsSubworkspace: semrushPromptId is empty string when createPromptsByIds returns no items', async () => { + it('handleCreatePromptsSubworkspace: semrushPromptId is empty string when createPromptsWithMetadata returns no items', async () => { const transport = makeTransport({ - createPromptsByIds: sinon.stub().resolves({}), + createPromptsWithMetadata: sinon.stub().resolves({}), }); const result = await handleCreatePromptsSubworkspace(transport, WS, { prompts: [{ diff --git a/test/support/serenity/handlers/prompts.test.js b/test/support/serenity/handlers/prompts.test.js index 8a21d6003a..b174c4cf65 100644 --- a/test/support/serenity/handlers/prompts.test.js +++ b/test/support/serenity/handlers/prompts.test.js @@ -21,6 +21,10 @@ import { handleUpdatePrompt, handleBulkDeletePrompts, makeTypeInjector, + resolveCallerId, + buildCreateMetadata, + buildUpdateMetadata, + resolveSort, } from '../../../../src/support/serenity/handlers/prompts.js'; import { ErrorWithStatusCode } from '../../../../src/support/utils.js'; import { SerenityTransportError } from '../../../../src/support/serenity/rest-transport.js'; @@ -37,6 +41,27 @@ use(sinonChai); const BRAND = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; const WORKSPACE = 'workspace-1'; +// Matches one v3 create item `{ name, metadata }` (LLMO-6289): the create stamp +// carries all four keys with created_* === updated_*. `by` is the expected +// caller id (undefined when a test omits callerId — the metadata still carries +// RFC 3339 timestamps). +const createItemMatch = (name, by) => sinon.match({ + name, + metadata: sinon.match({ + created_at: sinon.match.string, + created_by: by, + updated_at: sinon.match.string, + updated_by: by, + }), +}); + +// Matches the combined v3 text-edit PATCH body `{ name, metadata }` (LLMO-6289): +// an edit stamps ONLY the updated_* pair (created_* are kept by merge-patch). +const patchTextMatch = (name, by) => sinon.match({ + name, + metadata: sinon.match({ updated_at: sinon.match.string, updated_by: by }), +}); + // A classifier over BARE `type` values — the dimension is the tag's root, never // a prefix on its name. const classifyByBrandMention = (text) => (/\bacme\b/i.test(text) ? 'branded' : 'non-branded'); @@ -206,6 +231,11 @@ describe('handlers/prompts.js — handleListPrompts', () => { text: 'good prompt', tags: [], tagMap: {}, + // No upstream `metadata` on this item → all four authorship fields null. + createdAt: null, + createdBy: null, + updatedAt: null, + updatedBy: null, }); }); @@ -242,6 +272,10 @@ describe('handlers/prompts.js — handleListPrompts', () => { id: 't-1', name: 'awareness', parentId: null, path: null, }], tagMap: { awareness: 't-1' }, + createdAt: null, + createdBy: null, + updatedAt: null, + updatedBy: null, }); expect(result.items[0]).not.to.have.property('id'); expect(result.items[0]).not.to.have.property('semrushProjectId'); @@ -446,20 +480,20 @@ describe('handlers/prompts.js — handleListPrompts', () => { describe('handlers/prompts.js — handleCreatePrompts', () => { it('400s on empty prompts array (no upstream call)', async () => { - const transport = { createPromptsByIds: sinon.stub() }; + const transport = { createPromptsWithMetadata: sinon.stub() }; const dataAccess = makeDataAccess([]); await expect(handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { prompts: [], }, fakeLog())).to.be.rejectedWith(ErrorWithStatusCode); - expect(transport.createPromptsByIds).not.to.have.been.called; + expect(transport.createPromptsWithMetadata).not.to.have.been.called; }); // Review minor #4: maxItems=500 cap matches the OpenAPI declaration and // prevents an authenticated caller from submitting 10k+ items inside API // Gateway's request envelope. Defense-in-depth, not a correctness gate. it('400s when the prompts array exceeds maxItems=500', async () => { - const transport = { createPromptsByIds: sinon.stub() }; + const transport = { createPromptsWithMetadata: sinon.stub() }; const dataAccess = makeDataAccess([]); const tooMany = Array.from({ length: 501 }, (_, i) => ({ text: `prompt ${i}`, @@ -471,7 +505,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { await expect(handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { prompts: tooMany, }, fakeLog())).to.be.rejectedWith(ErrorWithStatusCode, /maxItems=500/); - expect(transport.createPromptsByIds).not.to.have.been.called; + expect(transport.createPromptsWithMetadata).not.to.have.been.called; }); // Branch coverage: body with no `prompts` key → Array.isArray fallback to [] @@ -486,7 +520,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { // `projects || []` fallback kicks in and every input is skipped. it('skips every input when allByBrandId returns null', async () => { const dataAccess = makeDataAccess(null); - const transport = { createPromptsByIds: sinon.stub() }; + const transport = { createPromptsWithMetadata: sinon.stub() }; const result = await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { prompts: [{ @@ -497,14 +531,14 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { expect(result.created).to.have.lengthOf(0); expect(result.skipped).to.have.lengthOf(1); expect(result.skipped[0].reason).to.match(/No market for slice/); - expect(transport.createPromptsByIds).to.have.callCount(0); + expect(transport.createPromptsWithMetadata).to.have.callCount(0); }); // Branch coverage: raw input with no `text` field → normalizePromptInput // returns null and the row lands in `skipped` with `text: ''`. it('skips inputs with no text field (raw.text undefined → empty string)', async () => { const dataAccess = makeDataAccess([]); - const transport = { createPromptsByIds: sinon.stub() }; + const transport = { createPromptsWithMetadata: sinon.stub() }; const result = await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { prompts: [{ geoTargetId: 2840, languageCode: 'en', tagIds: ['tag-1'] }], @@ -514,7 +548,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { expect(result.skipped[0].text).to.equal(''); }); - // Branch coverage: createPromptsByIds rejects with an error that has no + // Branch coverage: createPromptsWithMetadata rejects with an error that has no // `.status` field → `e.status || 500` defaults to 500. it('defaults failed.status to 500 when upstream error has no status property', async () => { const project = makeProject({ @@ -522,7 +556,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { - createPromptsByIds: sinon.stub().rejects(new Error('opaque failure')), + createPromptsWithMetadata: sinon.stub().rejects(new Error('opaque failure')), publishProject: sinon.stub().resolves(), }; @@ -536,7 +570,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); it('skips inputs missing required fields', async () => { - const transport = { createPromptsByIds: sinon.stub() }; + const transport = { createPromptsWithMetadata: sinon.stub() }; const dataAccess = makeDataAccess([]); const result = await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { @@ -553,7 +587,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'hello' }], existing_count: 0, }), publishProject: sinon.stub().resolves(), @@ -573,7 +607,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { text: 'hello', tagIds: ['tag-cat-1', 'tag-child-1'], }); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['tag-cat-1', 'tag-child-1']); + expect(transport.createPromptsWithMetadata).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', [createItemMatch('hello', undefined)], ['tag-cat-1', 'tag-child-1']); expect(transport.publishProject).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en'); }); @@ -586,7 +620,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en', }); const dataAccess = makeDataAccess([project]); - const transport = { createPromptsByIds: sinon.stub() }; + const transport = { createPromptsWithMetadata: sinon.stub() }; const result = await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { prompts: [{ @@ -595,7 +629,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }, fakeLog()); expect(result.skipped).to.have.lengthOf(1); - expect(transport.createPromptsByIds).to.not.have.been.called; + expect(transport.createPromptsWithMetadata).to.not.have.been.called; }); it('skips an input that supplies both tags and tagIds', async () => { @@ -603,7 +637,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en', }); const dataAccess = makeDataAccess([project]); - const transport = { createPromptsByIds: sinon.stub() }; + const transport = { createPromptsWithMetadata: sinon.stub() }; const result = await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { prompts: [{ @@ -612,7 +646,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }, fakeLog()); expect(result.skipped).to.have.lengthOf(1); - expect(transport.createPromptsByIds).to.not.have.been.called; + expect(transport.createPromptsWithMetadata).to.not.have.been.called; }); it('skips an input carrying an empty tags array alongside tagIds (presence-based rejection, not content-based)', async () => { @@ -625,7 +659,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en', }); const dataAccess = makeDataAccess([project]); - const transport = { createPromptsByIds: sinon.stub() }; + const transport = { createPromptsWithMetadata: sinon.stub() }; const result = await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { prompts: [{ @@ -634,16 +668,16 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }, fakeLog()); expect(result.skipped).to.have.lengthOf(1); - expect(transport.createPromptsByIds).to.not.have.been.called; + expect(transport.createPromptsWithMetadata).to.not.have.been.called; }); - it('drops falsy tagIds entries and returns empty semrushPromptId when createPromptsByIds has no items', async () => { + it('drops falsy tagIds entries and returns empty semrushPromptId when createPromptsWithMetadata has no items', async () => { const project = makeProject({ semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en', }); const dataAccess = makeDataAccess([project]); const transport = { - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 0, items: [], existing_count: 1, }), publishProject: sinon.stub().resolves(), @@ -657,16 +691,16 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { expect(result.created[0].semrushPromptId).to.equal(''); expect(result.created[0].tagIds).to.deep.equal(['keep']); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep']); + expect(transport.createPromptsWithMetadata).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', [createItemMatch('hello', undefined)], ['keep']); }); - it('returns empty semrushPromptId (not the string "undefined") when createPromptsByIds returns an item with no id', async () => { + it('returns empty semrushPromptId (not the string "undefined") when createPromptsWithMetadata returns an item with no id', async () => { const project = makeProject({ semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en', }); const dataAccess = makeDataAccess([project]); const transport = { - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 1, items: [{ name: 'hello' }], }), publishProject: sinon.stub().resolves(), @@ -687,7 +721,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'hello' }], }), publishProject: sinon.stub().resolves(), @@ -704,7 +738,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }, fakeLog()); expect(result.created[0].tagIds).to.deep.equal(['keep']); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep']); + expect(transport.createPromptsWithMetadata).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', [createItemMatch('hello', undefined)], ['keep']); }); it('caps a bulk-create tagIds array at MAX_TAG_IDS (50), mirroring the list-read query cap', async () => { @@ -713,7 +747,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'hello' }], }), publishProject: sinon.stub().resolves(), @@ -735,7 +769,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en', }); const dataAccess = makeDataAccess([project]); - const transport = { createPromptsByIds: sinon.stub() }; + const transport = { createPromptsWithMetadata: sinon.stub() }; const result = await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { prompts: [{ @@ -744,7 +778,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }, fakeLog()); expect(result.skipped).to.have.lengthOf(1); - expect(transport.createPromptsByIds).to.not.have.been.called; + expect(transport.createPromptsWithMetadata).to.not.have.been.called; }); it('skips inputs whose (geoTargetId, languageCode) slice has no row on the brand', async () => { @@ -753,7 +787,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([usEn]); const transport = { - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'good' }], }), publishProject: sinon.stub().resolves(), @@ -777,20 +811,20 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { reason: 'No market for slice (9999, xx)', }); // Only one upstream create — the orphan slice is filtered before any call. - expect(transport.createPromptsByIds).to.have.callCount(1); + expect(transport.createPromptsWithMetadata).to.have.callCount(1); }); - // Branch coverage: upstream createPromptsByIds rejects → that row enters the + // Branch coverage: upstream createPromptsWithMetadata rejects → that row enters the // `failed` bucket with the upstream error's status code, but the rest of the // batch continues. - it('reports per-input failures when upstream createPromptsByIds rejects', async () => { + it('reports per-input failures when upstream createPromptsWithMetadata rejects', async () => { const project = makeProject({ semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en', }); const dataAccess = makeDataAccess([project]); const err = Object.assign(new Error('rate limited'), { status: 429 }); const transport = { - createPromptsByIds: sinon.stub().rejects(err), + createPromptsWithMetadata: sinon.stub().rejects(err), publishProject: sinon.stub().resolves(), }; @@ -822,7 +856,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'ok' }], }), publishProject: sinon.stub().rejects(new Error('publish boom')), @@ -999,10 +1033,10 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { dataAccess.BrandSemrushProject.findBySlice.resolves(project); const transport = { - renamePrompt: sinon.stub().resolves({ id: 'sem-1', name: 'next', is_updated: true }), + patchPrompt: sinon.stub().resolves({ id: 'sem-1', name: 'next', is_updated: true }), updatePromptTagsByIds: sinon.stub().resolves(null), deletePromptsByIds: sinon.stub(), - createPromptsByIds: sinon.stub(), + createPromptsWithMetadata: sinon.stub(), publishProject: sinon.stub().resolves(), }; @@ -1027,7 +1061,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { text: 'next', tagIds: ['tag-cat-1'], }); - expect(transport.renamePrompt).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', 'sem-1', 'next'); + expect(transport.patchPrompt).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', 'sem-1', patchTextMatch('next', undefined)); expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly( WORKSPACE, 'proj-us-en', @@ -1035,7 +1069,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { ); // Nothing is deleted or created anywhere on the edit path. expect(transport.deletePromptsByIds).to.have.callCount(0); - expect(transport.createPromptsByIds).to.have.callCount(0); + expect(transport.createPromptsWithMetadata).to.have.callCount(0); expect(transport.publishProject).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en'); }); @@ -1047,7 +1081,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { dataAccess.BrandSemrushProject.findBySlice.resolves(project); const transport = { - renamePrompt: sinon.stub().resolves({ id: 'sem-1', name: 'next', is_updated: true }), + patchPrompt: sinon.stub().resolves({ id: 'sem-1', name: 'next', is_updated: true }), updatePromptTagsByIds: sinon.stub().resolves(null), publishProject: sinon.stub().resolves(), }; @@ -1080,7 +1114,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { dataAccess.BrandSemrushProject.findBySlice.resolves(project); const transport = { - renamePrompt: sinon.stub().resolves({ id: 'sem-1', name: 'next', is_updated: true }), + patchPrompt: sinon.stub().resolves({ id: 'sem-1', name: 'next', is_updated: true }), updatePromptTagsByIds: sinon.stub().resolves(null), publishProject: sinon.stub().resolves(), }; @@ -1179,7 +1213,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { // stub lets us assert callCount(0) so a regression that brings the // walk back fails this test. listPromptsByTags: sinon.stub(), - renamePrompt: sinon.stub().resolves({ id: 'sem-1', name: 'new text', is_updated: true }), + patchPrompt: sinon.stub().resolves({ id: 'sem-1', name: 'new text', is_updated: true }), updatePromptTagsByIds: sinon.stub().resolves(null), publishProject: sinon.stub().resolves(), }; @@ -1205,7 +1239,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { tagIds: ['tag-fresh'], }); expect(transport.listPromptsByTags).to.have.callCount(0); - expect(transport.renamePrompt).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', 'sem-1', 'new text'); + expect(transport.patchPrompt).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', 'sem-1', patchTextMatch('new text', undefined)); }); it('upstream rename 404 → return 404 promptNotFound (no tag write)', async () => { @@ -1220,7 +1254,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { // must NOT trip the promptNotFound path. const err = new SerenityTransportError(404, 'not found'); const transport = { - renamePrompt: sinon.stub().rejects(err), + patchPrompt: sinon.stub().rejects(err), updatePromptTagsByIds: sinon.stub(), }; @@ -1254,7 +1288,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { const err = new SerenityTransportError(409, 'conflict'); const transport = { - renamePrompt: sinon.stub().rejects(err), + patchPrompt: sinon.stub().rejects(err), updatePromptTagsByIds: sinon.stub(), publishProject: sinon.stub(), }; @@ -1283,7 +1317,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { const err = Object.assign(new Error('upstream 503'), { status: 503 }); const transport = { - renamePrompt: sinon.stub().rejects(err), + patchPrompt: sinon.stub().rejects(err), updatePromptTagsByIds: sinon.stub(), }; @@ -1313,7 +1347,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { const tagErr = Object.assign(new Error('tag write boom'), { status: 500 }); const transport = { - renamePrompt: sinon.stub().resolves({ id: 'sem-1', name: 'x', is_updated: true }), + patchPrompt: sinon.stub().resolves({ id: 'sem-1', name: 'x', is_updated: true }), updatePromptTagsByIds: sinon.stub().rejects(tagErr), publishProject: sinon.stub().resolves(), }; @@ -1330,10 +1364,10 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { }, log, )).to.be.rejectedWith(/tag write boom/); - expect(transport.renamePrompt).to.have.been.calledOnce; + expect(transport.patchPrompt).to.have.been.calledOnce; expect(transport.publishProject).to.not.have.been.called; expect(log.warn).to.have.been.calledOnceWith( - 'updatePromptTagsByIds failed after a successful rename — text updated, tags stale', + 'updatePromptTagsByIds failed after a successful text/metadata PATCH — text updated, tags stale', { semrushPromptId: 'sem-1', projectId: 'proj-us-en', error: 'tag write boom' }, ); }); @@ -1694,7 +1728,7 @@ describe('handlers/prompts.js — tag cache invalidation (Important #6)', () => } = setupCtx; // Mutation: handleCreatePrompts pushes a new prompt → invalidate. - transport.createPromptsByIds = sinon.stub().resolves({ + transport.createPromptsWithMetadata = sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'sem-new', name: 'fresh' }], }); transport.publishProject = sinon.stub().resolves(); @@ -1723,7 +1757,7 @@ describe('handlers/prompts.js — tag cache invalidation (Important #6)', () => handleListTags, transport, dataAccess, } = setupCtx; - transport.renamePrompt = sinon.stub().resolves({ id: 'sem-1', name: 'updated', is_updated: true }); + transport.patchPrompt = sinon.stub().resolves({ id: 'sem-1', name: 'updated', is_updated: true }); transport.updatePromptTagsByIds = sinon.stub().resolves(null); transport.publishProject = sinon.stub().resolves(); await handleUpdatePrompt(transport, dataAccess, BRAND, WORKSPACE, 'sem-1', { @@ -1813,7 +1847,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) const transport = { listProjectTags: makeListProjectTagsStub(), createProjectTags: sinon.stub(), - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'is Acme good?' }], }), publishProject: sinon.stub().resolves(), @@ -1832,10 +1866,10 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) expect(result.created[0].tagIds).to.deep.equal([ TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, ]); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly( + expect(transport.createPromptsWithMetadata).to.have.been.calledOnceWithExactly( WORKSPACE, 'proj-us-en', - ['is Acme good?'], + [createItemMatch('is Acme good?', undefined)], [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded], ); // The whole taxonomy already exists, so nothing is provisioned. @@ -1846,7 +1880,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) const dataAccess = makeDataAccess([project()]); const transport = { listProjectTags: makeListProjectTagsStub(), - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'best running shoes' }], }), publishProject: sinon.stub().resolves(), @@ -1885,7 +1919,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) ]; const transport = { listProjectTags: makeListProjectTagsStub(levels), - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'is Acme good?' }], }), publishProject: sinon.stub().resolves(), @@ -1911,7 +1945,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) const transport = { listProjectTags, createProjectTags, - createPromptsByIds: sinon.stub().resolves({ + createPromptsWithMetadata: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'is Acme good?' }], }), publishProject: sinon.stub().resolves(), @@ -1941,7 +1975,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) dataAccess.BrandSemrushProject.findBySlice.resolves(project()); const transport = { listProjectTags: makeListProjectTagsStub(), - renamePrompt: sinon.stub().resolves({ id: 'old-id', name: 'now mentions Acme', is_updated: true }), + patchPrompt: sinon.stub().resolves({ id: 'old-id', name: 'now mentions Acme', is_updated: true }), updatePromptTagsByIds: sinon.stub().resolves(null), publishProject: sinon.stub().resolves(), }; @@ -1976,7 +2010,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) dataAccess.BrandSemrushProject.findBySlice.resolves(project()); const transport = { listProjectTags: sinon.stub().rejects(new Error('tag tree unavailable')), - renamePrompt: sinon.stub().resolves(), + patchPrompt: sinon.stub().resolves(), updatePromptTagsByIds: sinon.stub(), publishProject: sinon.stub().resolves(), }; @@ -1988,7 +2022,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) tagIds: [TAG_IDS.categoryRunningShoes], }, fakeLog(), classifyByBrandMention)).to.be.rejectedWith(/tag tree unavailable/); - expect(transport.renamePrompt).to.not.have.been.called; + expect(transport.patchPrompt).to.not.have.been.called; expect(transport.updatePromptTagsByIds).to.not.have.been.called; }); }); @@ -2030,3 +2064,206 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }); }); }); + +describe('handlers/prompts.js — authorship metadata (LLMO-6289)', () => { + afterEach(() => sinon.restore()); + + describe('resolveCallerId', () => { + const withProfile = (profile) => ({ + attributes: { authInfo: { getProfile: () => profile } }, + }); + + it('prefers the profile user_id', () => { + expect(resolveCallerId(withProfile({ user_id: 'user-123', sub: 'sub-x' }))).to.equal('user-123'); + }); + + it('falls back to sub when user_id is absent', () => { + expect(resolveCallerId(withProfile({ sub: 'sub-x' }))).to.equal('sub-x'); + }); + + it('returns the unknown sentinel when neither is present', () => { + expect(resolveCallerId(withProfile({}))).to.equal('unknown'); + }); + + it('returns unknown when there is no auth profile at all', () => { + expect(resolveCallerId({})).to.equal('unknown'); + expect(resolveCallerId(undefined)).to.equal('unknown'); + }); + + it('never derives authorship from the forwarded bearer — only the profile', () => { + // A ctx carrying only a bearer (no profile) resolves to unknown, NOT the token. + const ctx = { pathInfo: { headers: { authorization: 'Bearer some-upstream-token' } } }; + expect(resolveCallerId(ctx)).to.equal('unknown'); + }); + + it('caps the id at 100 chars (the upstream CHECK bound)', () => { + const long = 'x'.repeat(250); + const id = resolveCallerId(withProfile({ user_id: long })); + expect(id).to.have.lengthOf(100); + expect(id).to.equal('x'.repeat(100)); + }); + }); + + describe('buildCreateMetadata / buildUpdateMetadata', () => { + it('build create metadata with all four keys, created_* === updated_*', () => { + const meta = buildCreateMetadata('user-9'); + expect(meta).to.have.all.keys('created_at', 'created_by', 'updated_at', 'updated_by'); + expect(meta.created_by).to.equal('user-9'); + expect(meta.updated_by).to.equal('user-9'); + expect(meta.created_at).to.equal(meta.updated_at); + // RFC 3339 UTC (ends with Z). + expect(meta.created_at).to.match(/^\d{4}-\d{2}-\d{2}T.*Z$/); + }); + + it('build update metadata with ONLY the updated pair (created_* kept by merge-patch)', () => { + const meta = buildUpdateMetadata('user-9'); + expect(meta).to.have.all.keys('updated_at', 'updated_by'); + expect(meta).to.not.have.property('created_at'); + expect(meta).to.not.have.property('created_by'); + expect(meta.updated_by).to.equal('user-9'); + expect(meta.updated_at).to.match(/^\d{4}-\d{2}-\d{2}T.*Z$/); + }); + }); + + describe('resolveSort', () => { + it('returns {} when neither sort nor order is supplied', () => { + expect(resolveSort({})).to.deep.equal({}); + expect(resolveSort({ order: 'asc' })).to.deep.equal({}); + }); + + it('accepts an allow-listed sort, defaulting order to desc', () => { + expect(resolveSort({ sort: 'metadata.created_at' })) + .to.deep.equal({ sort: 'metadata.created_at', order: 'desc' }); + }); + + it('accepts an explicit order (case-insensitive)', () => { + expect(resolveSort({ sort: 'metadata.updated_at', order: 'ASC' })) + .to.deep.equal({ sort: 'metadata.updated_at', order: 'asc' }); + }); + + it('throws 400 for a sort field outside the allow-list', () => { + expect(() => resolveSort({ sort: 'text' })).to.throw(ErrorWithStatusCode).with.property('status', 400); + }); + + it('throws 400 for an invalid order', () => { + expect(() => resolveSort({ sort: 'metadata.created_at', order: 'sideways' })) + .to.throw(ErrorWithStatusCode).with.property('status', 400); + }); + }); + + describe('handleListPrompts — metadata read + sort', () => { + it('maps item.metadata to the DTO createdAt/By + updatedAt/By fields', async () => { + const project = makeProject({ semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en' }); + const dataAccess = makeDataAccess([]); + dataAccess.BrandSemrushProject.findBySlice.resolves(project); + const transport = { + listPromptsByTags: sinon.stub().resolves({ + items: [{ + id: 'sem-1', + name: 'a prompt', + metadata: { + created_at: '2026-07-01T00:00:00Z', + created_by: 'user-a', + updated_at: '2026-07-02T00:00:00Z', + updated_by: 'user-b', + }, + }], + total: 1, + }), + }; + + const result = await handleListPrompts(transport, dataAccess, BRAND, WORKSPACE, { + geoTargetId: 2840, languageCode: 'en', + }); + + expect(result.items[0]).to.include({ + createdAt: '2026-07-01T00:00:00Z', + createdBy: 'user-a', + updatedAt: '2026-07-02T00:00:00Z', + updatedBy: 'user-b', + }); + }); + + it('forwards a valid sort/order to the transport', async () => { + const project = makeProject({ semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en' }); + const dataAccess = makeDataAccess([]); + dataAccess.BrandSemrushProject.findBySlice.resolves(project); + const transport = { listPromptsByTags: sinon.stub().resolves({ items: [], total: 0 }) }; + + await handleListPrompts(transport, dataAccess, BRAND, WORKSPACE, { + geoTargetId: 2840, languageCode: 'en', sort: 'metadata.updated_at', order: 'asc', + }); + + const [, , body] = transport.listPromptsByTags.firstCall.args; + expect(body.sort).to.equal('metadata.updated_at'); + expect(body.order).to.equal('asc'); + }); + + it('400s a sort field outside the allow-list before any upstream call', async () => { + const project = makeProject({ semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en' }); + const dataAccess = makeDataAccess([]); + dataAccess.BrandSemrushProject.findBySlice.resolves(project); + const transport = { listPromptsByTags: sinon.stub() }; + + await expect(handleListPrompts(transport, dataAccess, BRAND, WORKSPACE, { + geoTargetId: 2840, languageCode: 'en', sort: 'bogus', + })).to.be.rejectedWith(ErrorWithStatusCode); + expect(transport.listPromptsByTags).to.not.have.been.called; + }); + }); + + describe('handleCreatePrompts / handleUpdatePrompt stamp the resolved callerId', () => { + it('stamps created_* = the caller id on a create', async () => { + const project = makeProject({ semrushProjectId: 'proj-us-en', geoTargetId: 2840, languageCode: 'en' }); + const dataAccess = makeDataAccess([project]); + const transport = { + createPromptsWithMetadata: sinon.stub().resolves({ items: [{ id: 'new-id', name: 'hi' }] }), + publishProject: sinon.stub().resolves(), + }; + + await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { + prompts: [{ + text: 'hi', geoTargetId: 2840, languageCode: 'en', tagIds: ['tag-1'], + }], + }, fakeLog(), undefined, 'caller-42'); + + expect(transport.createPromptsWithMetadata).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', [createItemMatch('hi', 'caller-42')], ['tag-1']); + }); + + it('stamps updated_* = the caller id on an edit (created_* untouched)', async () => { + const project = { getSemrushProjectId: () => 'proj-us-en' }; + const dataAccess = makeDataAccess([]); + dataAccess.BrandSemrushProject.findBySlice.resolves(project); + const transport = { + patchPrompt: sinon.stub().resolves({ id: 'sem-1', name: 'new', is_updated: true }), + updatePromptTagsByIds: sinon.stub().resolves(null), + publishProject: sinon.stub().resolves(), + }; + + const result = await handleUpdatePrompt( + transport, + dataAccess, + BRAND, + WORKSPACE, + 'sem-1', + { + text: 'new', tagIds: ['tag-1'], geoTargetId: 2840, languageCode: 'en', + }, + fakeLog(), + undefined, + 'caller-42', + ); + + expect(result.status).to.equal(200); + expect(transport.patchPrompt).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', 'sem-1', patchTextMatch('new', 'caller-42')); + // The combined PATCH carries NO created_* — merge-patch keeps them. + const body = transport.patchPrompt.firstCall.args[3]; + expect(body.metadata).to.not.have.property('created_at'); + expect(body.metadata).to.not.have.property('created_by'); + // Tag write is metadata-free (separate v2 PUT). + expect(transport.updatePromptTagsByIds).to.have.been.calledOnce; + const tagArgs = transport.updatePromptTagsByIds.firstCall.args; + expect(tagArgs[2][0]).to.not.have.property('metadata'); + }); + }); +}); diff --git a/test/support/serenity/rest-transport-metadata.test.js b/test/support/serenity/rest-transport-metadata.test.js new file mode 100644 index 0000000000..bccc324690 --- /dev/null +++ b/test/support/serenity/rest-transport-metadata.test.js @@ -0,0 +1,123 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { use, expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; + +use(chaiAsPromised); +use(sinonChai); + +const IMS = 'ims-bearer-test-token'; +const WS = '11111111-2222-3333-4444-555555555555'; +const PID = 'proj-xyz'; +const TEST_ENV = { SEMRUSH_PROJECTS_BASE_URL: 'https://adobe-hackathon.semrush.com' }; + +/** + * The v3 prompt-authorship-metadata write methods (LLMO-6289) call facade + * operations the CURRENTLY-INSTALLED project-engine-client (1.14.0) does not yet + * expose — WP2 re-vendors the client to add them. To exercise the transport + * WRAPPERS (their `{ params, body }` shaping) before that dep bump lands, esmock + * replaces `createSerenityProjectEngineTransport` with a fake facade carrying the + * four operations as stubs, and asserts each wrapper delegates with the right + * init. Everything else in the module (the raw client, the user-manager client) + * is left as the real export. + */ +describe('Semrush REST transport — v3 metadata write surface (LLMO-6289)', () => { + const sandbox = sinon.createSandbox(); + let facade; + let createSerenityTransport; + + beforeEach(async () => { + facade = { + createPromptsWithMetadata: sandbox.stub().resolves({ items: [{ id: 'new-1', name: 'x' }] }), + patchPrompt: sandbox.stub().resolves({ id: 'p1', name: 'next', is_updated: true }), + patchPromptMetadata: sandbox.stub().resolves(null), + patchPromptsMetadataBatch: sandbox.stub().resolves(null), + }; + const mod = await esmock( + '../../../src/support/serenity/rest-transport.js', + { + '@adobe/spacecat-shared-project-engine-client': { + createSerenityProjectEngineTransport: () => facade, + createSerenityProjectEngineApiClient: () => ({ GET: sandbox.stub() }), + ProjectEngineApiError: class ProjectEngineApiError extends Error {}, + }, + '@adobe/spacecat-shared-user-manager-client': { + createSerenityUserManagerApiClient: () => ({}), + }, + }, + ); + createSerenityTransport = mod.createSerenityTransport; + }); + + afterEach(() => sandbox.restore()); + + it('createPromptsWithMetadata posts { items:[{name,metadata}], tag_ids } and returns the wrapper', async () => { + const transport = createSerenityTransport({ env: TEST_ENV, imsToken: IMS }); + const items = [{ + name: 'hello', + metadata: { + created_by: 'u1', created_at: 't', updated_by: 'u1', updated_at: 't', + }, + }]; + + const result = await transport.createPromptsWithMetadata(WS, PID, items, ['tag-1']); + + expect(facade.createPromptsWithMetadata).to.have.been.calledOnceWithExactly({ + params: { path: { id: WS, project_id: PID } }, + body: { items, tag_ids: ['tag-1'] }, + }); + expect(result.items).to.deep.equal([{ id: 'new-1', name: 'x' }]); + }); + + it('patchPrompt PATCHes {id} with the combined { name, metadata } body', async () => { + const transport = createSerenityTransport({ env: TEST_ENV, imsToken: IMS }); + const body = { name: 'next', metadata: { updated_by: 'u1', updated_at: 't' } }; + + await transport.patchPrompt(WS, PID, 'p1', body); + + expect(facade.patchPrompt).to.have.been.calledOnceWithExactly({ + params: { path: { id: WS, project_id: PID, prompt_id: 'p1' } }, + body, + }); + }); + + it('patchPromptMetadata PATCHes {id}/metadata with the merge-patch body', async () => { + const transport = createSerenityTransport({ env: TEST_ENV, imsToken: IMS }); + const metadata = { updated_by: 'u1', updated_at: 't', created_by: null }; + + await transport.patchPromptMetadata(WS, PID, 'p1', metadata); + + expect(facade.patchPromptMetadata).to.have.been.calledOnceWithExactly({ + params: { path: { id: WS, project_id: PID, prompt_id: 'p1' } }, + body: metadata, + }); + }); + + it('patchPromptsMetadataBatch PATCHes /metadata with { items:[{id,metadata}] }', async () => { + const transport = createSerenityTransport({ env: TEST_ENV, imsToken: IMS }); + const items = [ + { id: 'p1', metadata: { updated_by: 'u1' } }, + { id: 'p2', metadata: { updated_by: 'u2' } }, + ]; + + await transport.patchPromptsMetadataBatch(WS, PID, items); + + expect(facade.patchPromptsMetadataBatch).to.have.been.calledOnceWithExactly({ + params: { path: { id: WS, project_id: PID } }, + body: { items }, + }); + }); +}); diff --git a/test/support/serenity/rest-transport.test.js b/test/support/serenity/rest-transport.test.js index ed7cd0c677..aa7f2a578d 100644 --- a/test/support/serenity/rest-transport.test.js +++ b/test/support/serenity/rest-transport.test.js @@ -720,6 +720,33 @@ describe('Semrush REST transport', () => { expect(body).to.not.have.property('sort_dir'); expect(body.search).to.equal('photoshop'); }); + + it('forwards sort + order on the with-metadata list path (LLMO-6289)', async () => { + fetchStub.resolves(fetchOk({ items: [] })); + const transport = createSerenityTransport({ env: TEST_ENV, imsToken: IMS }); + + await transport.listPromptsByTags(WORKSPACE_ID, PROJECT_ID, { + sort: 'metadata.updated_at', + order: 'desc', + }); + + const call = await callOf(fetchStub); + const body = JSON.parse(call.body); + expect(body.sort).to.equal('metadata.updated_at'); + expect(body.order).to.equal('desc'); + }); + + it('omits sort/order entirely when no sort is requested (byte-for-byte legacy call)', async () => { + fetchStub.resolves(fetchOk({ items: [] })); + const transport = createSerenityTransport({ env: TEST_ENV, imsToken: IMS }); + + await transport.listPromptsByTags(WORKSPACE_ID, PROJECT_ID, { page: 1 }); + + const call = await callOf(fetchStub); + const body = JSON.parse(call.body); + expect(body).to.not.have.property('sort'); + expect(body).to.not.have.property('order'); + }); }); describe('createPromptsByIds', () => {