From 8eda64dce70178dcd6fceca104804a69ac1c4908 Mon Sep 17 00:00:00 2001 From: Alicia Adriani Date: Tue, 21 Jul 2026 16:32:07 +0200 Subject: [PATCH 1/2] feat(serenity): stamp prompt authorship metadata + DTO + sort/order (LLMO-6289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP1 of the serenity dimension-root metadata program. Stamps created_at/by + updated_at/by on every native prompt write, surfaces them on the list DTO, and adds server-side sort/order on the with-metadata read. - resolveCallerId(ctx): caller identity from authInfo.getProfile() (user_id ?? sub, else `unknown`, capped at 100 — the upstream CHECK bound). NEVER derived from the forwarded upstream bearer. Resolved once per request in the controller and threaded into every write handler in both prompt twins, createOnePrompt, and generateAndAttachPrompts (markets-subworkspace). - One shared stamping helper (buildCreateMetadata/buildUpdateMetadata) — RFC 3339 UTC, all four keys on create, updated pair on edit (merge-patch keeps created_*). - Transport (v3 metadata write surface over the regenerated client): createPromptsWithMetadata, patchPrompt (combined name+metadata), single + batch patch*Metadata; by_tags list forwards sort/order. Text edits go through the combined PATCH; the v2 tag PUT stays metadata-free (its stamp rides the always-run combined PATCH — two writes, no combined tag write). - buildPromptDto maps metadata.* → createdAt/By/updatedAt/By; sort/order allow-listed to metadata.created_at / metadata.updated_at and forwarded. - OpenAPI: SerenityPrompt authorship fields + sort/order params + contract test. Draft — merge only after WP2 (regenerated PE client) is released; the v3 facade methods land with that dep bump. it-postgres runs post-WP2-release only. Co-Authored-By: Claude Opus 4.8 --- docs/openapi/schemas.yaml | 28 ++ docs/openapi/serenity-api.yaml | 21 ++ src/controllers/serenity.js | 20 + src/support/serenity/brand-provisioning.js | 6 + .../serenity/handlers/markets-subworkspace.js | 32 +- .../serenity/handlers/prompts-subworkspace.js | 33 +- src/support/serenity/handlers/prompts.js | 184 ++++++++- src/support/serenity/rest-transport.js | 156 +++++++- test/controllers/serenity.test.js | 6 + test/openapi-contract/serenity-api.test.js | 9 +- .../serenity/brand-provisioning.test.js | 3 + .../dynamic-allocation-fronting.test.js | 6 +- .../handlers/markets-subworkspace.test.js | 66 ++-- .../handlers/prompts-subworkspace.test.js | 70 ++-- .../support/serenity/handlers/prompts.test.js | 355 +++++++++++++++--- .../serenity/rest-transport-metadata.test.js | 123 ++++++ test/support/serenity/rest-transport.test.js | 27 ++ 17 files changed, 988 insertions(+), 157 deletions(-) create mode 100644 test/support/serenity/rest-transport-metadata.test.js 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..51b5e2e3a6 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. 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..a378e06c48 100644 --- a/test/support/serenity/handlers/prompts-subworkspace.test.js +++ b/test/support/serenity/handlers/prompts-subworkspace.test.js @@ -46,11 +46,11 @@ 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( + patchPrompt: sinon.stub().callsFake( (ws, pid, promptId, newName) => Promise.resolve( { id: promptId, name: newName, is_updated: true }, ), @@ -64,6 +64,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 +119,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'); @@ -154,14 +174,14 @@ 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('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 +197,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 +216,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 +232,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 +250,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 +280,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 +296,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,14 +330,14 @@ 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; }); @@ -332,7 +352,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 +395,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 +445,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 +456,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 +473,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 +677,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', () => { From 927e8f315f9a4c9ce35fa65d3e7c85fe9485008b Mon Sep 17 00:00:00 2001 From: Alicia Adriani Date: Tue, 21 Jul 2026 17:41:55 +0200 Subject: [PATCH 2/2] test(serenity): close subworkspace sort/order + patchPrompt-stub gaps (LLMO-6289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses MysticatBot review on #2877: - Add subworkspace list sort/order tests (valid forwarded to transport; invalid sort/order → 400 before any upstream call; unsorted forwards neither) + a metadata→DTO mapping test — the subworkspace twin resolves its project via the live listing, so its forwarding path is independently testable from flat mode. - Fix the patchPrompt fake to match the production 4th-arg signature ({ name, metadata }, reading body.name) instead of treating it as a string. - Nit: add real-callerId stamping tests for the subworkspace create + update handlers (caller-42), proving the full stamp path rather than the undefined default. Co-Authored-By: Claude Opus 4.8 --- .../dynamic-allocation-fronting.test.js | 32 +++--- .../handlers/prompts-subworkspace.test.js | 100 +++++++++++++++++- 2 files changed, 115 insertions(+), 17 deletions(-) diff --git a/test/support/serenity/dynamic-allocation-fronting.test.js b/test/support/serenity/dynamic-allocation-fronting.test.js index 51b5e2e3a6..27c046cc41 100644 --- a/test/support/serenity/dynamic-allocation-fronting.test.js +++ b/test/support/serenity/dynamic-allocation-fronting.test.js @@ -599,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. @@ -627,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?'] }], }), @@ -654,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 () => { @@ -663,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) { @@ -674,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, @@ -694,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([]); @@ -706,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) { @@ -719,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, @@ -736,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/prompts-subworkspace.test.js b/test/support/serenity/handlers/prompts-subworkspace.test.js index a378e06c48..0cc829192f 100644 --- a/test/support/serenity/handlers/prompts-subworkspace.test.js +++ b/test/support/serenity/handlers/prompts-subworkspace.test.js @@ -50,9 +50,12 @@ function makeTransport(overrides = {}) { page: 1, total: 1, items: [{ id: 'new-prompt', name: 'p' }], }), deletePromptsByIds: sinon.stub().resolves(null), + // 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, newName) => Promise.resolve( - { id: promptId, name: newName, is_updated: true }, + (ws, pid, promptId, body) => Promise.resolve( + { id: promptId, name: body?.name ?? '', is_updated: true }, ), ), updatePromptTagsByIds: sinon.stub().resolves(null), @@ -146,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({ @@ -178,6 +249,16 @@ describe('prompts-subworkspace handlers', () => { 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 createPromptsWithMetadata itself (Rainer, live-verified) — a // disguised-quota 405 fires there, before any publish. getWorkspaceResources must be read (if @@ -341,6 +422,21 @@ describe('prompts-subworkspace handlers', () => { 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', {