diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 349d53adbe..e9486b3d37 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -654,6 +654,8 @@ paths: $ref: './llmo-api.yaml#/llmo-onboard-site' /sites/{siteId}/llmo/offboard: $ref: './llmo-api.yaml#/llmo-offboard' + /sites/{siteId}/prompt-suggestion-schedules: + $ref: './llmo-api.yaml#/llmo-prompt-suggestion-schedules' /sites/{siteId}/llmo/opportunities-reviewed: $ref: './llmo-api.yaml#/llmo-opportunities-reviewed' /sites/{siteId}/llmo/brand-claims: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index 1223056ccb..fbc41863ab 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -1347,6 +1347,50 @@ llmo-offboard: security: - api_key: [ ] +llmo-prompt-suggestion-schedules: + parameters: + - $ref: './parameters.yaml#/siteId' + post: + tags: + - llmo + summary: Provision recurring prompt-suggestion schedules for a site + description: | + (Re)provisions the recurring DRS prompt-suggestion pipelines for a site, + keyed off the site's CURRENT LLMO tier (re-derived server-side — the caller + cannot supply a tier). Only PAID sites get recurring schedules; a non-paying + site is a no-op (skipped), because the trial one-shot run is an + onboarding-only behavior. The underlying schedule creation is idempotent, so + repeat calls are safe and already-existing schedules count as success. + + Admin-or-S2S: reachable by admin `x-api-key` callers and by S2S consumers + holding the `promptSuggestionSchedule:write` capability (used by the + fulfillment-worker after a Commerce trial→paid flip and by a backfill + reconciler — both follow-ups). + operationId: createPromptSuggestionSchedules + responses: + '200': + description: >- + Provisioning completed. For a paying site the per-pipeline results are + returned; for a non-paying site the response indicates it was skipped. + `allSucceeded` reflects whether every pipeline succeeded (already-existing + schedules count as success); inspect `results` for per-pipeline status. + content: + application/json: + schema: + $ref: './schemas.yaml#/PromptSuggestionSchedulesResult' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + llmo-questions: parameters: - $ref: './parameters.yaml#/siteId' diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index 781597e454..9a6e427911 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -4714,6 +4714,71 @@ LlmoQuestion: example: ['product', 'pricing', 'features'] additionalProperties: true +PromptSuggestionScheduleResult: + type: object + description: Outcome of provisioning a single prompt-suggestion pipeline for a site. + properties: + providerId: + type: string + description: The DRS provider id of the prompt-suggestion pipeline. + example: 'prompt_generation_semrush' + status: + type: string + enum: ['created', 'already-existed', 'submitted', 'failed'] + description: >- + Per-pipeline outcome. `created` — a new recurring schedule was registered; + `already-existed` — an idempotent no-op (treated as success); `submitted` — + a one-shot run was submitted (trial path); `failed` — provisioning failed + (see `error`). + example: 'created' + error: + type: string + description: Failure message, present only when `status` is `failed`. + example: 'DRS POST /schedules failed: 500' + required: + - providerId + - status + +PromptSuggestionSchedulesResult: + type: object + description: >- + Result of (re)provisioning a site's recurring prompt-suggestion schedules. + When the site is not on the paying tier, `skipped` is true and no schedules + are created. + properties: + siteId: + type: string + format: uuid + description: The site the schedules were provisioned for. + example: '48656b02-62cb-46c0-b3fe-4a1a0f0c3b2f' + isPaying: + type: boolean + description: Whether the site is currently on the paying LLMO tier (re-derived server-side). + example: true + skipped: + type: boolean + description: True when no work was done because the site is not on the paying tier. + example: false + reason: + type: string + description: Why provisioning was skipped, present only when `skipped` is true. + example: 'not-paying' + allSucceeded: + type: boolean + description: True iff every pipeline succeeded (already-existing schedules count as success). + example: true + results: + type: array + description: Per-pipeline provisioning outcomes (empty when skipped). + items: + $ref: '#/PromptSuggestionScheduleResult' + required: + - siteId + - isPaying + - skipped + - allSucceeded + - results + LlmoQuestions: type: object description: LLMO questions configuration containing both human and AI questions diff --git a/src/controllers/entitlements.js b/src/controllers/entitlements.js index b1d048940d..63f8965242 100644 --- a/src/controllers/entitlements.js +++ b/src/controllers/entitlements.js @@ -25,13 +25,55 @@ import { import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access'; import TierClient from '@adobe/spacecat-shared-tier-client'; +import DrsClient from '@adobe/spacecat-shared-drs-client'; import { EntitlementDto } from '../dto/entitlement.js'; import { SiteEnrollmentDto } from '../dto/site-enrollment.js'; import AccessControlUtil from '../support/access-control-util.js'; +import { ensurePromptSuggestionSchedules } from '../support/prompt-suggestion-schedules.js'; const VALID_PRODUCT_CODES = new Set(Object.values(EntitlementModel.PRODUCT_CODES)); const FREE_TRIAL_TIER = EntitlementModel.TIERS.FREE_TRIAL; +const PAID_TIER = EntitlementModel.TIERS.PAID; +const LLMO_PRODUCT_CODE = EntitlementModel.PRODUCT_CODES.LLMO; + +/** + * Best-effort reaction to a trial→paid LLMO transition: (re)provisions the site's + * recurring DRS prompt-suggestion schedules. Never throws — the entitlement + * operation must succeed regardless of this side-effect (mirrors the best-effort + * schedule registration on the onboarding path). The dedicated endpoint + * (`POST /sites/:siteId/prompt-suggestion-schedules`) is the reusable equivalent; + * this inlines the same shared helper so an admin tier flip is not silently + * missed while the fulfillment-worker call + reconciler are still follow-ups. + * + * @param {object} context - Request context (for DrsClient + log). + * @param {string} siteId - Site UUID. + * @param {object} log - Logger. + * @returns {Promise} + */ +async function reactToLlmoTrialToPaid(context, siteId, log) { + try { + const drsClient = DrsClient.createFrom(context); + if (!drsClient.isConfigured()) { + log.debug(`[prompt-suggestion-schedules] DRS not configured, skipping trial→paid reaction for site ${siteId}`); + return; + } + const { results, allSucceeded } = await ensurePromptSuggestionSchedules({ + drsClient, + siteId, + isPaying: true, + log, + }); + const summary = results.map((r) => `${r.providerId}:${r.status}`).join(','); + if (allSucceeded) { + log.info(`[prompt-suggestion-schedules] trial→paid provisioned recurring schedules site_id=${siteId} results=${summary}`); + } else { + log.error(`[prompt-suggestion-schedules] trial→paid: one or more pipelines failed site_id=${siteId} results=${summary}`); + } + } catch (e) { + log.error(`[prompt-suggestion-schedules] trial→paid reaction failed for site ${siteId}: ${e.message}`); + } +} /** * Entitlements controller. Provides methods to read entitlements by organization. @@ -185,7 +227,23 @@ function EntitlementsController(ctx) { site, productCode, ); + // Read the prior tier BEFORE the create so we can detect a trial→paid + // transition without changing the shared createEntitlement return shape + // (mirrors resolveProvisioningTier in support/tier-provisioning.js). + const existing = await tierClient.checkValidEntitlement(); + const prevTier = existing.entitlement?.getTier?.() ?? null; + const { entitlement, siteEnrollment } = await tierClient.createEntitlement(tier); + + // LLMO trial→paid: (re)provision recurring prompt-suggestion schedules. + // Best-effort — never fails the entitlement op. + const resultTier = entitlement?.getTier?.(); + if (productCode === LLMO_PRODUCT_CODE + && prevTier !== PAID_TIER + && resultTier === PAID_TIER) { + await reactToLlmoTrialToPaid(context, siteId, context.log); + } + return created({ entitlement: EntitlementDto.toJSON(entitlement), siteEnrollment: SiteEnrollmentDto.toJSON(siteEnrollment), diff --git a/src/controllers/llmo/llmo-onboarding.js b/src/controllers/llmo/llmo-onboarding.js index ac63e6470f..c4fc63025d 100644 --- a/src/controllers/llmo/llmo-onboarding.js +++ b/src/controllers/llmo/llmo-onboarding.js @@ -35,6 +35,23 @@ import { import { upsertFeatureFlag } from '../../support/feature-flags-storage.js'; import { detectCdnForDomain } from '../../support/cdn-detection.js'; import { upsertBrand } from '../../support/brands-storage.js'; +import { + PROMPT_SUGGESTION_PIPELINES, + registerPromptSuggestionSchedule, + ensurePromptSuggestionSchedules, + isPayingLlmoSite, +} from '../../support/prompt-suggestion-schedules.js'; + +// The tier-gated prompt-suggestion schedule helpers moved to a reusable support +// module so the POST /sites/:siteId/prompt-suggestion-schedules endpoint and a +// future reconciler can share them. Re-exported here so importers/tests that +// referenced them from this controller keep working. +export { + PROMPT_SUGGESTION_PIPELINES, + registerPromptSuggestionSchedule, + ensurePromptSuggestionSchedules, + isPayingLlmoSite, +}; // LLMO Constants const LLMO_PRODUCT_CODE = EntitlementModel.PRODUCT_CODES.LLMO; @@ -191,213 +208,6 @@ export async function triggerBrandalfOnboardingJob({ return drsJob; } -// Cadence labels accepted by DRS `createSchedule` for the recurring -// "prompt suggestion" pipelines (the `SCHEDULE_CADENCES` enum in the drs-client). -// DRS derives the concrete cron expression server-side from the label -// (frequency:'cron', with per-site hour jitter for twice_monthly) — we never send -// raw cron, so a misconfigured/leaked caller cannot schedule a fleet-wide Fargate -// storm; an unknown value is rejected server-side. -// 'twice_monthly' → 1st & 15th (honest label; not a true 14-day interval) -// 'quarterly' → 1st of Jan/Apr/Jul/Oct -// Kept as local literals (matching SCHEDULE_CADENCES values) so this module loads -// against the currently-installed client; can be swapped for the imported -// SCHEDULE_CADENCES const once drs-client 1.14.0 is installed. -// See local/drs-prompt-suggestions-schedules-onboarding-plan.md ("Cadence expression"). -const DRS_CADENCE_TWICE_MONTHLY = 'twice_monthly'; -const DRS_CADENCE_QUARTERLY = 'quarterly'; - -// The recurring "prompt suggestion" pipelines registered for every newly -// onboarded v2 site. Each providerId+cadence pair is declared exactly once here -// and iterated by registerPromptSuggestionSchedules, so adding a pipeline is a -// one-line change and no per-provider code can drift out of sync. -// -// NOTE on `prompt_generation_agentic_traffic` ("citation attempts"): "Citation -// Attempt" is the renamed *output* of the agentic-traffic pipeline — there is no -// standalone citation-attempt provider — so this id will NOT grep from the word -// "citation". DRS's per-provider Fargate whitelist (`AT_FARGATE_WHITELIST`) is -// the real enablement gate; leave agentic-traffic un-whitelisted in an env until -// its Postgres-format migration is confirmed healthy (plan Phase 0.2) so the -// best-effort first run cannot automate a known-failing pipeline. -export const PROMPT_SUGGESTION_PIPELINES = [ - { name: 'SEMrush prompts', providerId: 'prompt_generation_semrush', cadence: DRS_CADENCE_TWICE_MONTHLY }, - { name: 'citation attempts', providerId: 'prompt_generation_agentic_traffic', cadence: DRS_CADENCE_TWICE_MONTHLY }, - { name: 'synthetic personas', providerId: 'prompt_generation_synthetic_personas', cadence: DRS_CADENCE_QUARTERLY }, -]; - -/** - * Reports whether a site is on the paying (PAID) LLMO tier, which decides the - * prompt-suggestion behavior at onboarding: PAID → a recurring schedule; - * anything else (FREE_TRIAL, or an entitlement that cannot be read) → a single - * on-demand run. - * - * Fails safe to trial: if the current LLMO entitlement is absent or the lookup - * throws, this returns `false` (and logs a WARN) rather than assuming PAID, so a - * site whose paying status is unknown never gets a recurring, fleet-wide Fargate - * schedule. - * - * @param {object} site - The SpaceCat site model. - * @param {object} context - The request context (passed to TierClient). - * @returns {Promise} True only when the current LLMO tier is PAID. - */ -export async function isPayingLlmoSite(site, context) { - const { log } = context; - try { - const tierClient = await TierClient.createForSite(context, site, LLMO_PRODUCT_CODE); - const { entitlement } = await tierClient.checkValidEntitlement(); - if (!entitlement) { - log.warn(`Could not determine LLMO tier for site ${site.getId()} ` - + '(no entitlement found); defaulting to one-time (trial) prompt-suggestion runs'); - return false; - } - return entitlement.getTier() === EntitlementModel.TIERS.PAID; - } catch (error) { - log.warn(`Failed to read LLMO tier for site ${site.getId()}; ` - + `defaulting to one-time (trial) prompt-suggestion runs: ${error.message}`); - return false; - } -} - -/** - * Runs one prompt-suggestion provider for a newly onboarded site, tier-gated. - * Shared body for {@link registerPromptSuggestionSchedules}. - * - * - **Paying (`isPaying === true`)**: registers a recurring DRS schedule - * (`createSchedule`) with an immediate first run. - * - **Trial / non-paying (any other value)**: submits a single on-demand run - * (`submitJob`) with NO recurring schedule. These three providers are - * on-demand-capable, so a one-shot job is valid and gives a trial site its - * first suggestions without committing recurring Fargate load. - * - * Split error semantics (see the V2 caller): the immediate first run is - * best-effort — if it produces nothing (e.g. brand/base-prompt data not present - * yet) the next scheduled run (paying) self-heals. A `createSchedule` (schedule - * REGISTRATION) failure is NOT best-effort: the site would never get a recurring - * schedule and nothing self-heals, so it propagates to the caller which logs it - * at ERROR. This helper therefore does not swallow the failure itself. The - * one-shot `submitJob` failure is handled the same way (propagated, logged by the - * caller) so both paths share one per-pipeline try/catch. - * - * NOTE: `createSchedule` derives the tenant-isolation key from `siteId` - * server-side and REJECTS any caller-supplied imsOrgId, so we deliberately do not - * thread imsOrgId/orgId into it. - * - * @param {object} params - * @param {object} params.drsClient - Configured DRS client. - * @param {string} params.providerId - DRS provider id to schedule/run. - * @param {string} params.cadence - One of DRS_CADENCE_* labels (paying path only). - * @param {string} params.siteId - SpaceCat site UUID. - * @param {boolean} params.isPaying - True → recurring schedule; else one-shot run. - * @param {object} params.log - Logger. - * @param {Function} [params.say] - Optional Slack say callback. - * @returns {Promise} The createSchedule/submitJob result, or null when - * DRS is not configured. - */ -export async function registerPromptSuggestionSchedule({ - drsClient, providerId, cadence, siteId, isPaying, log, say = () => {}, -}) { - if (!drsClient.isConfigured()) { - log.debug(`DRS client not configured, skipping ${providerId} schedule for site ${siteId}`); - return null; - } - - // Trial / non-paying (or indeterminate tier): run the pipeline ONCE via an - // on-demand submitJob, with no recurring schedule. - if (!isPaying) { - const job = await drsClient.submitJob({ - provider_id: providerId, - source: 'onboarding', - priority: 'HIGH', - parameters: { siteId }, - }); - log.info(`Submitted one-time DRS ${providerId} run (trial site) ` - + `job=${job?.job_id ?? 'unknown'} for site ${siteId}`); - say(`:zap: Submitted one-time DRS ${providerId} run (trial site) for site ${siteId}`); - return job; - } - - // Paying (PAID): register the recurring schedule with an immediate first run. - // Default enable_brand_presence off (plan Phase 0.4): these prompt-suggestion - // pipelines must not push unexpected load into the brand-presence pipeline / SNS - // allowlist unless a site is explicitly BP-enabled. `providerIds` is an array - // (the job_config.provider_ids envelope) even for a single-provider pipeline. - const result = await drsClient.createSchedule({ - siteId, - providerIds: [providerId], - cadence, - description: `${providerId} prompt-suggestion schedule (onboarding)`, - enableBrandPresence: false, - triggerImmediately: true, - }); - - log.info(`Registered DRS ${providerId} schedule ${result?.scheduleId ?? 'unknown'} ` - + `(cadence=${cadence}) for site ${siteId}${result?.alreadyExisted ? ' (already existed)' : ''}`); - say(`:calendar_spiral: Registered DRS ${providerId} schedule for site ${siteId}`); - return result; -} - -/** - * Runs every prompt-suggestion pipeline (see {@link PROMPT_SUGGESTION_PIPELINES}) - * for a newly onboarded v2 site, tier-gated: paying sites get a recurring - * schedule (immediate first run), trial/non-paying sites get a single on-demand - * run per pipeline (no recurring schedule). See {@link registerPromptSuggestionSchedule}. - * - * Error ownership is single-layer: each pipeline is wrapped in its own try/catch - * that owns the failure (logs at ERROR + emits an operator Slack signal) and - * never rethrows, so one pipeline's failure (schedule REGISTRATION on the paying - * path, or the one-shot submit on the trial path) neither aborts onboarding nor - * masks the other pipelines. Because no callback rejects, the pipelines run under - * `Promise.all` (not `allSettled`). - * - * @param {object} params - * @param {object} params.drsClient - Configured DRS client. - * @param {string} params.siteId - SpaceCat site UUID. - * @param {boolean} params.isPaying - True → recurring schedules; else one-shot runs. - * @param {object} params.log - Logger. - * @param {Function} [params.say] - Optional Slack say callback. - * @returns {Promise} - */ -export async function registerPromptSuggestionSchedules({ - drsClient, siteId, isPaying, log, say = () => {}, -}) { - // Short-circuit once for an unconfigured client instead of letting each - // per-pipeline registerPromptSuggestionSchedule log "not configured" N times. - if (!drsClient.isConfigured()) { - log.debug(`DRS client not configured, skipping prompt-suggestion schedules for site ${siteId}`); - return { completed: true }; - } - - await Promise.all( - PROMPT_SUGGESTION_PIPELINES.map(async ({ name, providerId, cadence }) => { - try { - await registerPromptSuggestionSchedule({ - drsClient, providerId, cadence, siteId, isPaying, log, say, - }); - } catch (scheduleError) { - // Pipeline REGISTRATION/SUBMIT failure (distinct from the best-effort - // immediate run): on the paying path the site would never get a recurring - // schedule and nothing self-heals; on the trial path the one-shot run - // never fires. Either way, surface it loudly with full context. Onboarding - // still succeeds, mirroring the brand-activation side-effect handling in - // brands.js (activateBrand). `status` is the upstream HTTP status when the - // DRS client attaches one, else 'unknown'. - const status = scheduleError.status ?? 'unknown'; - // Wording tracks the branch: paying → createSchedule (recurring schedule), - // trial/non-paying → submitJob (one-shot run). "schedule" alone would - // misdescribe the trial-path failure. - const mode = isPaying ? 'schedule' : 'one-shot run'; - log.error(`Failed to run/register DRS ${name} prompt-suggestion (${mode}) ` - + `provider_id=${providerId} site_id=${siteId} status=${status}: ${scheduleError.message}`); - say(`:warning: Failed to run/register DRS ${name} prompt-suggestion (${mode}) ` - + `for site ${siteId} (will need manual trigger)`); - } - }), - ); - - // Sentinel so the caller can distinguish "finished" from a settleWithin timeout - // (which resolves to the fallback, not this object). - return { completed: true }; -} - // submitOnboardingPromptGenerationJob removed — prompt generation is now // triggered by DRS after Brandalf completes (LLMO-4258, option b). @@ -1691,9 +1501,9 @@ export async function activateBrandAndGeneratePrompts({ // otherwise stall onboarding. On timeout we stop waiting — createSchedule is // idempotent, the durable outcome is the server-side schedule row (paying) // or a submitted job (trial), and per-pipeline ERROR logging inside - // registerPromptSuggestionSchedules stays deterministic. + // ensurePromptSuggestionSchedules stays deterministic. const scheduleResult = await settleWithin( - registerPromptSuggestionSchedules({ + ensurePromptSuggestionSchedules({ drsClient, siteId: site.getId(), isPaying, diff --git a/src/controllers/llmo/prompt-suggestion-schedules.js b/src/controllers/llmo/prompt-suggestion-schedules.js new file mode 100644 index 0000000000..4b16b42cc7 --- /dev/null +++ b/src/controllers/llmo/prompt-suggestion-schedules.js @@ -0,0 +1,174 @@ +/* + * 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 { + ok, + badRequest, + notFound, + forbidden, + internalServerError, +} from '@adobe/spacecat-shared-http-utils'; +import { isNonEmptyObject, isValidUUID } from '@adobe/spacecat-shared-utils'; +import DrsClient from '@adobe/spacecat-shared-drs-client'; + +import AccessControlUtil from '../../support/access-control-util.js'; +import { CAP_PROMPT_SUGGESTION_SCHEDULE_WRITE } from '../../routes/capability-constants.js'; +import { + ensurePromptSuggestionSchedules, + isPayingLlmoSite, +} from '../../support/prompt-suggestion-schedules.js'; + +const ROUTE = 'POST /sites/:siteId/prompt-suggestion-schedules'; + +/** + * Controller for the reusable per-site prompt-suggestion schedule provisioning + * endpoint (`POST /sites/:siteId/prompt-suggestion-schedules`). + * + * This is the api-service slice of the recurring-schedule creation flow that also + * runs as a best-effort onboarding side-effect. It exists so other components can + * (re)provision a site's recurring DRS prompt-suggestion pipelines out of band: + * - the fulfillment-worker, after a Commerce trial→paid tier flip (follow-up), + * - a reconciler that backfills PAID LLMO sites missing schedules (follow-up), + * - the admin trial→paid reaction in entitlements.createSiteEntitlement. + * + * The single mechanism is the site's CURRENT LLMO tier, re-derived server-side — + * the caller may not supply a tier/isPaying. Only paying sites get recurring + * schedules; a non-paying site is a no-op (skipped) because the trial one-shot run + * is an onboarding-only behavior (submitJob is not idempotent, so a + * re-provision/backfill endpoint must be paying-only). The underlying + * createSchedule is idempotent, so repeat calls are safe and already-existing + * schedules count as success. + * + * @param {object} ctx - Request context. + * @returns {object} Prompt-suggestion schedules controller. + * @constructor + */ +function PromptSuggestionSchedulesController(ctx) { + if (!isNonEmptyObject(ctx)) { + throw new Error('Context required'); + } + + const { dataAccess, log } = ctx; + if (!isNonEmptyObject(dataAccess)) { + throw new Error('Data access required'); + } + + const { Site } = dataAccess; + const accessControlUtil = AccessControlUtil.fromContext(ctx); + + /** + * Dual-layer write authorization: admin bypass first, then a fresh consumer + * fetch (`hasS2SCapability`) for S2S consumers. Returns a forbidden Response + * when denied, null when access is granted. + * @param {object} context - Request context. + * @returns {Promise} + */ + const authorizeWrite = async (context) => { + const requestId = context?.invocation?.id || 'unknown'; + const isAdmin = accessControlUtil.hasAdminAccess(); + const s2sResult = isAdmin + ? { allowed: false, reason: 'admin-bypass' } + : await accessControlUtil.hasS2SCapability(CAP_PROMPT_SUGGESTION_SCHEDULE_WRITE); + if (!isAdmin && !s2sResult.allowed) { + log.info(`[acl] Denied ${ROUTE} - reason=${s2sResult.reason} clientId=${s2sResult.clientId || 'n/a'} consumerId=${s2sResult.consumerId || 'n/a'} requestId=${requestId}`); + return forbidden('Forbidden'); + } + if (s2sResult.allowed) { + log.info(`[s2s] ${ROUTE} granted clientId=${s2sResult.clientId || 'n/a'} consumerId=${s2sResult.consumerId || 'n/a'} capability=${CAP_PROMPT_SUGGESTION_SCHEDULE_WRITE} requestId=${requestId}`); + } + return null; + }; + + /** + * (Re)provisions the recurring DRS prompt-suggestion schedules for a site, if + * (and only if) it is currently on the paying LLMO tier. + * @param {object} context - Request context. + * @returns {Promise} + */ + const createSchedules = async (context) => { + const denied = await authorizeWrite(context); + if (denied) { + return denied; + } + + const { siteId } = context.params; + if (!isValidUUID(siteId)) { + return badRequest('Site ID required'); + } + + const clientId = context.s2sConsumer?.getClientId?.() || 'n/a'; + + try { + const site = await Site.findById(siteId); + if (!site) { + return notFound('Site not found'); + } + + let drsClient; + try { + drsClient = DrsClient.createFrom(context); + } catch (drsClientError) { + log.error(`[prompt-suggestion-schedules] DRS client creation failed site_id=${siteId} clientId=${clientId}: ${drsClientError.message}`); + return internalServerError('DRS client is not available'); + } + if (!drsClient.isConfigured()) { + log.error(`[prompt-suggestion-schedules] DRS client not configured site_id=${siteId} clientId=${clientId}`); + return internalServerError('DRS client is not configured'); + } + + // Re-derive the tier server-side; never trust a caller-supplied tier. + const isPaying = await isPayingLlmoSite(site, context); + if (!isPaying) { + log.info(`[prompt-suggestion-schedules] Skipped (site not paying) site_id=${siteId} clientId=${clientId}`); + return ok({ + siteId, + isPaying: false, + skipped: true, + reason: 'not-paying', + allSucceeded: true, + results: [], + }); + } + + const { results, allSucceeded } = await ensurePromptSuggestionSchedules({ + drsClient, + siteId, + isPaying: true, + log, + }); + + const summary = results.map((r) => `${r.providerId}:${r.status}`).join(','); + if (allSucceeded) { + log.info(`[prompt-suggestion-schedules] Provisioned recurring schedules site_id=${siteId} clientId=${clientId} results=${summary}`); + } else { + log.error(`[prompt-suggestion-schedules] One or more pipelines failed site_id=${siteId} clientId=${clientId} results=${summary}`); + } + + return ok({ + siteId, + isPaying: true, + skipped: false, + allSucceeded, + results, + }); + } catch (e) { + log.error(`[prompt-suggestion-schedules] Failed site_id=${siteId} clientId=${clientId}: ${e.message}`); + return internalServerError('Failed to provision prompt-suggestion schedules'); + } + }; + + return { + createSchedules, + }; +} + +export default PromptSuggestionSchedulesController; diff --git a/src/index.js b/src/index.js index fd19bccc55..6d32b6da1a 100644 --- a/src/index.js +++ b/src/index.js @@ -85,6 +85,7 @@ import LlmoCloudFrontController from './controllers/llmo/llmo-cloudfront.js'; import LlmoAkamaiController from './controllers/llmo/llmo-akamai.js'; import LlmoMysticatController from './controllers/llmo/llmo-mysticat-controller.js'; import LlmoOpportunitiesController from './controllers/llmo/opportunities/llmo-opportunities-controller.js'; +import PromptSuggestionSchedulesController from './controllers/llmo/prompt-suggestion-schedules.js'; import FanoutReportController from './controllers/llmo/fanout-report.js'; import UserActivitiesController from './controllers/user-activities.js'; import SiteEnrollmentsController from './controllers/site-enrollments.js'; @@ -298,6 +299,7 @@ async function run(request, context) { const elementsController = ElementsController(context, log, context.env); const proxyController = ProxyController(); const taskManagementController = TaskManagementController(context); + const promptSuggestionSchedulesController = PromptSuggestionSchedulesController(context); const routeHandlers = getRouteHandlers( auditsController, @@ -365,6 +367,7 @@ async function run(request, context) { proxyController, taskManagementController, redirectsController, + promptSuggestionSchedulesController, ); const routeMatch = matchPath(method, suffix, routeHandlers); diff --git a/src/routes/capability-constants.js b/src/routes/capability-constants.js index f4fc3c1787..16a2157888 100644 --- a/src/routes/capability-constants.js +++ b/src/routes/capability-constants.js @@ -32,3 +32,5 @@ export const CAP_FIX_ENTITY_CREATE = 'fixEntity:create'; export const CAP_SUGGESTION_WRITE = 'suggestion:write'; export const CAP_TRIAL_USER_READ = 'trialUser:read'; + +export const CAP_PROMPT_SUGGESTION_SCHEDULE_WRITE = 'promptSuggestionSchedule:write'; diff --git a/src/routes/facs-capabilities.js b/src/routes/facs-capabilities.js index 780299ef30..0ac4afb964 100644 --- a/src/routes/facs-capabilities.js +++ b/src/routes/facs-capabilities.js @@ -179,6 +179,9 @@ const routeFacsCapabilities = { 'PATCH /sites/:siteId/:auditType', // hasAdminAccess (sites-audits-toggle) 'POST /sites/:siteId/site-enrollments', // hasAdminAccess 'POST /sites/:siteId/entitlements', // hasAdminAccess + // Prompt-suggestion schedule (re-)provisioning — admin-or-S2S (dedicated + // promptSuggestionSchedule:write capability); not a customer FACS surface. + 'POST /sites/:siteId/prompt-suggestion-schedules', // authorizeWrite (admin || S2S cap) 'POST /projects', // hasAdminAccess 'DELETE /projects/:projectId', // hasAdminAccess 'POST /organizations', // hasAdminAccess diff --git a/src/routes/index.js b/src/routes/index.js index 9617ca0088..12c0f9f6b0 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -111,6 +111,7 @@ function isStaticRoute(routePattern) { * @param {Object} proxyController - URL proxy controller for client-side previews. * @param {Object} taskManagementController - Task-management (Jira ticket creation) controller. * @param {Object} redirectsController - ASO dispatcher redirect-overlay controller. + * @param {Object} promptSuggestionSchedulesController - LLMO prompt-suggestion schedule controller. * @return {{staticRoutes: {}, dynamicRoutes: {}}} - An object with static and dynamic routes. */ export default function getRouteHandlers( @@ -179,6 +180,7 @@ export default function getRouteHandlers( proxyController, taskManagementController, redirectsController, + promptSuggestionSchedulesController, ) { const staticRoutes = {}; const dynamicRoutes = {}; @@ -542,6 +544,7 @@ export default function getRouteHandlers( 'POST /v2/orgs/:spaceCatId/llmo/onboard-site': llmoController.onboardSiteOnly, 'POST /llmo/onboard/update-query-index': llmoController.updateQueryIndex, 'POST /sites/:siteId/llmo/offboard': llmoController.offboardCustomer, + 'POST /sites/:siteId/prompt-suggestion-schedules': promptSuggestionSchedulesController.createSchedules, 'POST /sites/:siteId/llmo/edge-optimize-config': llmoController.createOrUpdateEdgeConfig, 'GET /sites/:siteId/llmo/edge-optimize-config': llmoController.getEdgeConfig, 'POST /sites/:siteId/llmo/edge-optimize-config/stage': llmoController.createOrUpdateStageEdgeConfig, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index fd81e138d1..0849924686 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -15,6 +15,7 @@ import { CAP_CONFIGURATION_WRITE, CAP_FIX_ENTITY_CREATE, CAP_ORG_READ_ALL, + CAP_PROMPT_SUGGESTION_SCHEDULE_WRITE, CAP_SITE_CREATE, CAP_SITE_READ_ALL, CAP_SUGGESTION_WRITE, @@ -771,6 +772,13 @@ const routeRequiredCapabilities = { // Suggestion grants 'DELETE /sites/:siteId/suggestions/grants/:grantId': CAP_SUGGESTION_WRITE, + + // Prompt-suggestion schedules — per-site (re-)provisioning of the recurring DRS + // prompt-suggestion pipelines. Admin-or-S2S: reachable by the fulfillment-worker + // (after a Commerce trial→paid flip) and a reconciler, so it is exposed to S2S + // consumers under a dedicated capability rather than left admin-only. The tier + // is re-derived server-side; the caller cannot supply it. + 'POST /sites/:siteId/prompt-suggestion-schedules': CAP_PROMPT_SUGGESTION_SCHEDULE_WRITE, }; export default routeRequiredCapabilities; diff --git a/src/support/prompt-suggestion-schedules.js b/src/support/prompt-suggestion-schedules.js new file mode 100644 index 0000000000..62daa8d3c5 --- /dev/null +++ b/src/support/prompt-suggestion-schedules.js @@ -0,0 +1,251 @@ +/* + * 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 { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js'; +import TierClient from '@adobe/spacecat-shared-tier-client'; + +const LLMO_PRODUCT_CODE = EntitlementModel.PRODUCT_CODES.LLMO; + +// Cadence labels accepted by DRS `createSchedule` for the recurring +// "prompt suggestion" pipelines (the `SCHEDULE_CADENCES` enum in the drs-client). +// DRS derives the concrete cron expression server-side from the label +// (frequency:'cron', with per-site hour jitter for twice_monthly) — we never send +// raw cron, so a misconfigured/leaked caller cannot schedule a fleet-wide Fargate +// storm; an unknown value is rejected server-side. +// 'twice_monthly' → 1st & 15th (honest label; not a true 14-day interval) +// 'quarterly' → 1st of Jan/Apr/Jul/Oct +// Kept as local literals (matching SCHEDULE_CADENCES values) so this module loads +// against the currently-installed client; can be swapped for the imported +// SCHEDULE_CADENCES const once drs-client 1.14.0 is installed. +// See local/drs-prompt-suggestions-schedules-onboarding-plan.md ("Cadence expression"). +const DRS_CADENCE_TWICE_MONTHLY = 'twice_monthly'; +const DRS_CADENCE_QUARTERLY = 'quarterly'; + +// The recurring "prompt suggestion" pipelines registered for every paying LLMO +// site. Each providerId+cadence pair is declared exactly once here and iterated +// by ensurePromptSuggestionSchedules, so adding a pipeline is a one-line change +// and no per-provider code can drift out of sync. +// +// NOTE on `prompt_generation_agentic_traffic` ("citation attempts"): "Citation +// Attempt" is the renamed *output* of the agentic-traffic pipeline — there is no +// standalone citation-attempt provider — so this id will NOT grep from the word +// "citation". DRS's per-provider Fargate whitelist (`AT_FARGATE_WHITELIST`) is +// the real enablement gate; leave agentic-traffic un-whitelisted in an env until +// its Postgres-format migration is confirmed healthy (plan Phase 0.2) so the +// best-effort first run cannot automate a known-failing pipeline. +export const PROMPT_SUGGESTION_PIPELINES = [ + { name: 'SEMrush prompts', providerId: 'prompt_generation_semrush', cadence: DRS_CADENCE_TWICE_MONTHLY }, + { name: 'citation attempts', providerId: 'prompt_generation_agentic_traffic', cadence: DRS_CADENCE_TWICE_MONTHLY }, + { name: 'synthetic personas', providerId: 'prompt_generation_synthetic_personas', cadence: DRS_CADENCE_QUARTERLY }, +]; + +/** + * Reports whether a site is on the paying (PAID) LLMO tier, which decides the + * prompt-suggestion behavior: PAID → a recurring schedule; anything else + * (FREE_TRIAL, or an entitlement that cannot be read) → a single on-demand run + * (onboarding path only) / skipped (re-provision endpoint). + * + * Fails safe to trial: if the current LLMO entitlement is absent or the lookup + * throws, this returns `false` (and logs a WARN) rather than assuming PAID, so a + * site whose paying status is unknown never gets a recurring, fleet-wide Fargate + * schedule. + * + * @param {object} site - The SpaceCat site model. + * @param {object} context - The request context (passed to TierClient). + * @returns {Promise} True only when the current LLMO tier is PAID. + */ +export async function isPayingLlmoSite(site, context) { + const { log } = context; + try { + const tierClient = await TierClient.createForSite(context, site, LLMO_PRODUCT_CODE); + const { entitlement } = await tierClient.checkValidEntitlement(); + if (!entitlement) { + log.warn(`Could not determine LLMO tier for site ${site.getId()} ` + + '(no entitlement found); defaulting to one-time (trial) prompt-suggestion runs'); + return false; + } + return entitlement.getTier() === EntitlementModel.TIERS.PAID; + } catch (error) { + log.warn(`Failed to read LLMO tier for site ${site.getId()}; ` + + `defaulting to one-time (trial) prompt-suggestion runs: ${error.message}`); + return false; + } +} + +/** + * Runs one prompt-suggestion provider for a site, tier-gated. Shared body for + * {@link ensurePromptSuggestionSchedules}. + * + * - **Paying (`isPaying === true`)**: registers a recurring DRS schedule + * (`createSchedule`) with an immediate first run. + * - **Trial / non-paying (any other value)**: submits a single on-demand run + * (`submitJob`) with NO recurring schedule. These three providers are + * on-demand-capable, so a one-shot job is valid and gives a trial site its + * first suggestions without committing recurring Fargate load. + * + * Split error semantics: the immediate first run is best-effort — if it produces + * nothing (e.g. brand/base-prompt data not present yet) the next scheduled run + * (paying) self-heals. A `createSchedule` (schedule REGISTRATION) failure is NOT + * best-effort: the site would never get a recurring schedule and nothing + * self-heals, so it propagates to the caller. This helper therefore does not + * swallow the failure itself; {@link ensurePromptSuggestionSchedules} catches it + * per-pipeline and records a 'failed' status. The one-shot `submitJob` failure is + * handled the same way (propagated, recorded by the caller) so both paths share + * one per-pipeline try/catch. + * + * NOTE: `createSchedule` derives the tenant-isolation key from `siteId` + * server-side and REJECTS any caller-supplied imsOrgId, so we deliberately do not + * thread imsOrgId/orgId into it. + * + * @param {object} params + * @param {object} params.drsClient - Configured DRS client. + * @param {string} params.providerId - DRS provider id to schedule/run. + * @param {string} params.cadence - One of DRS_CADENCE_* labels (paying path only). + * @param {string} params.siteId - SpaceCat site UUID. + * @param {boolean} params.isPaying - True → recurring schedule; else one-shot run. + * @param {object} params.log - Logger. + * @param {Function} [params.say] - Optional Slack say callback. + * @returns {Promise} The createSchedule/submitJob result, or null when + * DRS is not configured. + */ +export async function registerPromptSuggestionSchedule({ + drsClient, providerId, cadence, siteId, isPaying, log, say = () => {}, +}) { + if (!drsClient.isConfigured()) { + log.debug(`DRS client not configured, skipping ${providerId} schedule for site ${siteId}`); + return null; + } + + // Trial / non-paying (or indeterminate tier): run the pipeline ONCE via an + // on-demand submitJob, with no recurring schedule. + if (!isPaying) { + const job = await drsClient.submitJob({ + provider_id: providerId, + source: 'onboarding', + priority: 'HIGH', + parameters: { siteId }, + }); + log.info(`Submitted one-time DRS ${providerId} run (trial site) ` + + `job=${job?.job_id ?? 'unknown'} for site ${siteId}`); + say(`:zap: Submitted one-time DRS ${providerId} run (trial site) for site ${siteId}`); + return job; + } + + // Paying (PAID): register the recurring schedule with an immediate first run. + // Default enable_brand_presence off (plan Phase 0.4): these prompt-suggestion + // pipelines must not push unexpected load into the brand-presence pipeline / SNS + // allowlist unless a site is explicitly BP-enabled. `providerIds` is an array + // (the job_config.provider_ids envelope) even for a single-provider pipeline. + const result = await drsClient.createSchedule({ + siteId, + providerIds: [providerId], + cadence, + description: `${providerId} prompt-suggestion schedule (onboarding)`, + enableBrandPresence: false, + triggerImmediately: true, + }); + + log.info(`Registered DRS ${providerId} schedule ${result?.scheduleId ?? 'unknown'} ` + + `(cadence=${cadence}) for site ${siteId}${result?.alreadyExisted ? ' (already existed)' : ''}`); + say(`:calendar_spiral: Registered DRS ${providerId} schedule for site ${siteId}`); + return result; +} + +/** + * Idempotently ensures every prompt-suggestion pipeline (see + * {@link PROMPT_SUGGESTION_PIPELINES}) is provisioned for a site, tier-gated: + * paying sites get a recurring schedule (immediate first run), trial/non-paying + * sites get a single on-demand run per pipeline (no recurring schedule). See + * {@link registerPromptSuggestionSchedule}. + * + * Error ownership is single-layer: each pipeline is wrapped in its own try/catch + * that owns the failure (logs at ERROR + emits an operator Slack signal) and + * never rethrows, so one pipeline's failure (schedule REGISTRATION on the paying + * path, or the one-shot submit on the trial path) neither aborts the batch nor + * masks the other pipelines. Because no callback rejects, the pipelines run under + * `Promise.all` (not `allSettled`). + * + * Failure contract: instead of an opaque sentinel, this returns per-pipeline + * results so callers (the onboarding side-effect, the re-provision endpoint, a + * reconciler) can report real outcomes: + * + * { + * results: [{ providerId, status, error? }], + * allSucceeded: boolean, + * } + * + * `status` is one of: 'created' (new recurring schedule), 'already-existed' + * (idempotent no-op — treated as success), 'submitted' (trial one-shot run), or + * 'failed' (with `error`). `allSucceeded` is true iff no pipeline failed. A + * not-configured DRS client short-circuits to `{ results: [], allSucceeded: true }`. + * + * @param {object} params + * @param {object} params.drsClient - Configured DRS client. + * @param {string} params.siteId - SpaceCat site UUID. + * @param {boolean} params.isPaying - True → recurring schedules; else one-shot runs. + * @param {object} params.log - Logger. + * @param {Function} [params.say] - Optional Slack say callback. + * @returns {Promise<{results: Array, allSucceeded: boolean}>} Per-pipeline + * results ({providerId, status, error?}) and an aggregate success flag. + */ +export async function ensurePromptSuggestionSchedules({ + drsClient, siteId, isPaying, log, say = () => {}, +}) { + // Short-circuit once for an unconfigured client instead of letting each + // per-pipeline registerPromptSuggestionSchedule log "not configured" N times. + if (!drsClient.isConfigured()) { + log.debug(`DRS client not configured, skipping prompt-suggestion schedules for site ${siteId}`); + return { results: [], allSucceeded: true }; + } + + const results = await Promise.all( + PROMPT_SUGGESTION_PIPELINES.map(async ({ name, providerId, cadence }) => { + try { + const result = await registerPromptSuggestionSchedule({ + drsClient, providerId, cadence, siteId, isPaying, log, say, + }); + // Map the raw DRS result to a per-pipeline status. already-existed is an + // idempotent no-op and counts as success. + let status; + if (!isPaying) { + status = 'submitted'; + } else if (result?.alreadyExisted) { + status = 'already-existed'; + } else { + status = 'created'; + } + return { providerId, status }; + } catch (scheduleError) { + // Pipeline REGISTRATION/SUBMIT failure (distinct from the best-effort + // immediate run): on the paying path the site would never get a recurring + // schedule and nothing self-heals; on the trial path the one-shot run + // never fires. Either way, surface it loudly with full context. The batch + // still completes, mirroring the brand-activation side-effect handling in + // brands.js (activateBrand). `status` is the upstream HTTP status when the + // DRS client attaches one, else 'unknown'. + const status = scheduleError.status ?? 'unknown'; + // Wording tracks the branch: paying → createSchedule (recurring schedule), + // trial/non-paying → submitJob (one-shot run). "schedule" alone would + // misdescribe the trial-path failure. + const mode = isPaying ? 'schedule' : 'one-shot run'; + log.error(`Failed to run/register DRS ${name} prompt-suggestion (${mode}) ` + + `provider_id=${providerId} site_id=${siteId} status=${status}: ${scheduleError.message}`); + say(`:warning: Failed to run/register DRS ${name} prompt-suggestion (${mode}) ` + + `for site ${siteId} (will need manual trigger)`); + return { providerId, status: 'failed', error: scheduleError.message }; + } + }), + ); + + const allSucceeded = results.every((r) => r.status !== 'failed'); + return { results, allSucceeded }; +} diff --git a/test/controllers/entitlements.test.js b/test/controllers/entitlements.test.js index d65b81d01a..b52c71b295 100644 --- a/test/controllers/entitlements.test.js +++ b/test/controllers/entitlements.test.js @@ -17,6 +17,7 @@ import sinon from 'sinon'; import AuthInfo from '@adobe/spacecat-shared-http-utils/src/auth/auth-info.js'; import TierClient from '@adobe/spacecat-shared-tier-client'; +import DrsClient from '@adobe/spacecat-shared-drs-client'; import EntitlementsController from '../../src/controllers/entitlements.js'; import AccessControlUtil from '../../src/support/access-control-util.js'; @@ -660,6 +661,9 @@ describe('Entitlements Controller', () => { entitlement: mockCreatedEntitlement, siteEnrollment: mockCreatedSiteEnrollment, }); + // Prior-tier read used to detect a trial→paid transition. Default: no prior + // entitlement, and mockCreatedEntitlement is FREE_TRIAL, so no reaction fires. + mockTierClient.checkValidEntitlement = sandbox.stub().resolves({ entitlement: null }); }); it('should ensure entitlement + site enrollment for admin user with ASO product', async () => { @@ -955,5 +959,94 @@ describe('Entitlements Controller', () => { `Error ensuring entitlement for site ${siteId}: ${siteErr.message}`, ); }); + + describe('LLMO trial→paid prompt-suggestion reaction', () => { + const buildEntitlement = (tier) => ({ + getId: () => 'entitlement-site-123', + getOrganizationId: () => organizationId, + getProductCode: () => 'LLMO', + getTier: () => tier, + getQuotas: () => ({}), + getCreatedAt: () => '2023-01-01T00:00:00Z', + getUpdatedAt: () => '2023-01-01T00:00:00Z', + getUpdatedBy: () => 'admin@example.com', + }); + + let fakeDrsClient; + + beforeEach(() => { + fakeDrsClient = { + isConfigured: sandbox.stub().returns(true), + createSchedule: sandbox.stub().resolves({ scheduleId: 's', alreadyExisted: false }), + submitJob: sandbox.stub().resolves({ job_id: 'j' }), + }; + sandbox.stub(DrsClient, 'createFrom').returns(fakeDrsClient); + }); + + const buildContext = () => ({ + params: { siteId }, + data: { productCode: 'LLMO', tier: 'PAID' }, + log: { + info: sinon.stub(), debug: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), + }, + }); + + it('fires recurring-schedule provisioning on a trial→paid transition', async () => { + mockTierClient.checkValidEntitlement.resolves({ entitlement: buildEntitlement('FREE_TRIAL') }); + mockTierClient.createEntitlement.resolves({ + entitlement: buildEntitlement('PAID'), + siteEnrollment: mockCreatedSiteEnrollment, + }); + + const result = await entitlementController.createSiteEntitlement(buildContext()); + + expect(result.status).to.equal(201); + // One recurring schedule per prompt-suggestion pipeline. + expect(fakeDrsClient.createSchedule).to.have.been.calledThrice; + expect(fakeDrsClient.submitJob).to.not.have.been.called; + }); + + it('does not fire on a paid→paid no-op', async () => { + mockTierClient.checkValidEntitlement.resolves({ entitlement: buildEntitlement('PAID') }); + mockTierClient.createEntitlement.resolves({ + entitlement: buildEntitlement('PAID'), + siteEnrollment: mockCreatedSiteEnrollment, + }); + + const result = await entitlementController.createSiteEntitlement(buildContext()); + + expect(result.status).to.equal(201); + expect(fakeDrsClient.createSchedule).to.not.have.been.called; + }); + + it('does not fire for a non-LLMO product even on trial→paid', async () => { + mockTierClient.checkValidEntitlement.resolves({ entitlement: buildEntitlement('FREE_TRIAL') }); + mockTierClient.createEntitlement.resolves({ + entitlement: { ...buildEntitlement('PAID'), getProductCode: () => 'ASO' }, + siteEnrollment: mockCreatedSiteEnrollment, + }); + const context = buildContext(); + context.data = { productCode: 'ASO', tier: 'PAID' }; + + const result = await entitlementController.createSiteEntitlement(context); + + expect(result.status).to.equal(201); + expect(fakeDrsClient.createSchedule).to.not.have.been.called; + }); + + it('never fails the entitlement op when the reaction throws', async () => { + mockTierClient.checkValidEntitlement.resolves({ entitlement: buildEntitlement('FREE_TRIAL') }); + mockTierClient.createEntitlement.resolves({ + entitlement: buildEntitlement('PAID'), + siteEnrollment: mockCreatedSiteEnrollment, + }); + // DRS client creation throws — reaction helper must swallow it. + DrsClient.createFrom.throws(new Error('DRS unavailable')); + + const result = await entitlementController.createSiteEntitlement(buildContext()); + + expect(result.status).to.equal(201); + }); + }); }); }); diff --git a/test/controllers/llmo/llmo-onboarding.test.js b/test/controllers/llmo/llmo-onboarding.test.js index b7a9dde68e..34adfa47bc 100644 --- a/test/controllers/llmo/llmo-onboarding.test.js +++ b/test/controllers/llmo/llmo-onboarding.test.js @@ -302,6 +302,39 @@ describe('LLMO Onboarding Functions', () => { return deps; }; + // The tier-gated prompt-suggestion helpers (isPayingLlmoSite, + // ensurePromptSuggestionSchedules) moved to src/support/prompt-suggestion-schedules.js, + // a TRANSITIVE dep of the onboarding controller. esmock's 2nd-arg (local) mocks + // only reach the target's direct imports, so the tier-client/entitlement doubles + // would NOT reach the shared module. Rather than mock tree-wide with esmock's 3rd + // (global) arg — whose per-call cost and cross-test accumulation in + // `global.mockKeys` slowed every later esmock in this file past its mocha timeout + // (the 25-test hang) — we mock the shared module AT ITS OWN BOUNDARIES (TierClient + // + Entitlement) via a nested esmock, then inject that mocked module as a LOCAL dep + // of the onboarding controller. The real isPayingLlmoSite / ensurePromptSuggestionSchedules + // logic still runs (so createSchedule/submitJob assertions hold), but against the + // doubles — and no global esmock state leaks between tests. + const SHARED_SCHEDULES_MODULE = '../../../src/support/prompt-suggestion-schedules.js'; + const TIER_CLIENT_MODULE = '@adobe/spacecat-shared-tier-client'; + const ENTITLEMENT_MODULE = '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js'; + const esmockOnboarding = async (deps) => { + // Forward only the shared module's own imports (TierClient + Entitlement) to + // the nested mock; anything else in `deps` (drs-client, storages, ...) reaches + // the onboarding controller directly as a local mock below. + const sharedDeps = {}; + if (deps[TIER_CLIENT_MODULE]) { + sharedDeps[TIER_CLIENT_MODULE] = deps[TIER_CLIENT_MODULE]; + } + if (deps[ENTITLEMENT_MODULE]) { + sharedDeps[ENTITLEMENT_MODULE] = deps[ENTITLEMENT_MODULE]; + } + const sharedModule = await esmock(SHARED_SCHEDULES_MODULE, sharedDeps); + return esmock('../../../src/controllers/llmo/llmo-onboarding.js', { + ...deps, + [SHARED_SCHEDULES_MODULE]: sharedModule, + }); + }; + /** * Sets up and returns a mocked performLlmoOffboarding function for testing. * @param {Object} options - Mock options @@ -1554,8 +1587,7 @@ describe('LLMO Onboarding Functions', () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); const mockUpsertBrand = sinon.stub().resolves({ id: 'brand-x' }); - const { performLlmoOnboarding: onboard } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: onboard } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -1636,8 +1668,7 @@ describe('LLMO Onboarding Functions', () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); const mockUpsertBrand = sinon.stub().resolves({ id: 'brand-x' }); - const { performLlmoOnboarding: onboard } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: onboard } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -1701,8 +1732,7 @@ describe('LLMO Onboarding Functions', () => { const { mockDrsClient } = buildTrackableDrsClient(); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: onboard } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: onboard } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -1761,8 +1791,7 @@ describe('LLMO Onboarding Functions', () => { const { mockDrsClient } = buildTrackableDrsClient(); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: onboard } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: onboard } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -1885,8 +1914,7 @@ describe('LLMO Onboarding Functions', () => { const mockUpsertBrand = sinon.stub().resolves({ id: 'brand-123', name: 'Test Brand' }); // Mock the Config import - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -2107,8 +2135,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: onboardWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: onboardWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -2225,8 +2252,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: onboardWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: onboardWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -2333,8 +2359,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: onboardWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: onboardWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -2440,8 +2465,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: onboardWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: onboardWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -2545,8 +2569,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: onboardWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: onboardWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -2636,8 +2659,7 @@ describe('LLMO Onboarding Functions', () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); const mockDetectCdnForDomain = sinon.stub().resolves('aem-cs-fastly'); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -2728,8 +2750,7 @@ describe('LLMO Onboarding Functions', () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); const mockDetectCdnForDomain = sinon.stub().resolves('byocdn-other'); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -2819,8 +2840,7 @@ describe('LLMO Onboarding Functions', () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); const mockDetectCdnForDomain = sinon.stub().rejects(new Error('DNS exploded')); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -2901,8 +2921,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -2996,8 +3015,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -3088,8 +3106,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(sinon, { isConfigured: false }); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -3170,8 +3187,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(sinon, { submitPromptGenerationJob }); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -3255,8 +3271,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -3344,8 +3359,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(sinon, { submitPromptGenerationJob }); const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -3433,8 +3447,7 @@ describe('LLMO Onboarding Functions', () => { // DRS client not configured const mockDrsClient = createMockDrsClient(sinon, { isConfigured: false }); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -3525,8 +3538,7 @@ describe('LLMO Onboarding Functions', () => { submitJob: sinon.stub().rejects(new Error('Brandalf API connection failed')), }); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -3615,8 +3627,7 @@ describe('LLMO Onboarding Functions', () => { const mockOctokit = createMockOctokit(sinon, { sha: 'test-sha-456' }); // Mock the module - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -3705,8 +3716,7 @@ describe('LLMO Onboarding Functions', () => { ); const mockOctokit = createMockOctokit(); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -3785,8 +3795,7 @@ describe('LLMO Onboarding Functions', () => { const mockOctokit = createMockOctokit(); const { repos: { createOrUpdateFileContents } } = mockOctokit(); - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -3907,8 +3916,7 @@ describe('LLMO Onboarding Functions', () => { const mockOctokit = createMockOctokit(); // Mock the Config import - const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { performLlmoOnboarding: performLlmoOnboardingWithMocks } = await esmockOnboarding( createCommonEsmockDependencies({ mockTierClient, mockTracingFetch, @@ -4392,8 +4400,7 @@ describe('LLMO Onboarding Functions', () => { describe('ensureInitialCustomerConfigV2', () => { it('throws when PostgREST is not available', async () => { - const { ensureInitialCustomerConfigV2 } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { ensureInitialCustomerConfigV2 } = await esmockOnboarding( createCommonEsmockDependencies(), ); @@ -4421,8 +4428,7 @@ describe('LLMO Onboarding Functions', () => { it('creates and writes the initial v2 config when one does not exist', async () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { ensureInitialCustomerConfigV2 } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { ensureInitialCustomerConfigV2 } = await esmockOnboarding( createCommonEsmockDependencies({ mockCustomerConfigV2Storage }), ); @@ -4470,8 +4476,7 @@ describe('LLMO Onboarding Functions', () => { it('uses authInfo.getProfile email when profile.email is not available', async () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(); - const { ensureInitialCustomerConfigV2 } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { ensureInitialCustomerConfigV2 } = await esmockOnboarding( createCommonEsmockDependencies({ mockCustomerConfigV2Storage }), ); @@ -4511,8 +4516,7 @@ describe('LLMO Onboarding Functions', () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(sinon, { readCustomerConfigV2FromPostgres: sinon.stub().resolves(existingConfig), }); - const { ensureInitialCustomerConfigV2 } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { ensureInitialCustomerConfigV2 } = await esmockOnboarding( createCommonEsmockDependencies({ mockCustomerConfigV2Storage }), ); @@ -4546,8 +4550,7 @@ describe('LLMO Onboarding Functions', () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(sinon, { readCustomerConfigV2FromPostgres: sinon.stub().resolves(existingConfig), }); - const { ensureInitialCustomerConfigV2 } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { ensureInitialCustomerConfigV2 } = await esmockOnboarding( createCommonEsmockDependencies({ mockCustomerConfigV2Storage }), ); @@ -4598,8 +4601,7 @@ describe('LLMO Onboarding Functions', () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(sinon, { readCustomerConfigV2FromPostgres: sinon.stub().resolves(existingConfig), }); - const { ensureInitialCustomerConfigV2 } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { ensureInitialCustomerConfigV2 } = await esmockOnboarding( createCommonEsmockDependencies({ mockCustomerConfigV2Storage }), ); @@ -4633,8 +4635,7 @@ describe('LLMO Onboarding Functions', () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(sinon, { readCustomerConfigV2FromPostgres: sinon.stub().resolves(existingConfig), }); - const { ensureInitialCustomerConfigV2 } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { ensureInitialCustomerConfigV2 } = await esmockOnboarding( createCommonEsmockDependencies({ mockCustomerConfigV2Storage }), ); @@ -4661,8 +4662,7 @@ describe('LLMO Onboarding Functions', () => { const mockCustomerConfigV2Storage = createMockCustomerConfigV2Storage(sinon, { readCustomerConfigV2FromPostgres: sinon.stub().resolves(existingConfig), }); - const { ensureInitialCustomerConfigV2 } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { ensureInitialCustomerConfigV2 } = await esmockOnboarding( createCommonEsmockDependencies({ mockCustomerConfigV2Storage }), ); @@ -5937,47 +5937,49 @@ describe('LLMO Onboarding Functions', () => { expect(log.info).to.have.been.calledWithMatch(/prompt_generation_semrush/); }); - it('registerPromptSuggestionSchedules resolves to a completion sentinel on success (paying)', async () => { - // The caller distinguishes "finished" from a settleWithin timeout by this - // sentinel (timeout resolves to the null fallback instead). + it('ensurePromptSuggestionSchedules returns per-pipeline results on success (paying)', async () => { + // The onboarding caller distinguishes "finished" from a settleWithin timeout + // by a truthy return (timeout resolves to the null fallback instead). const drsClient = buildDrsClient( sandbox.stub().resolves({ scheduleId: 'sched-1', alreadyExisted: false }), ); - const result = await onboardingModule.registerPromptSuggestionSchedules({ + const result = await onboardingModule.ensurePromptSuggestionSchedules({ drsClient, siteId: 'site-123', isPaying: true, log: buildLog(), say: sandbox.stub(), }); - expect(result).to.deep.equal({ completed: true }); + expect(result.allSucceeded).to.equal(true); + expect(result.results.map((r) => r.status)).to.deep.equal(['created', 'created', 'created']); // Paying → recurring schedule per pipeline, no one-shot submits. expect(drsClient.createSchedule).to.have.been.calledThrice; expect(drsClient.submitJob).to.not.have.been.called; }); - it('registerPromptSuggestionSchedules submits one-time runs (no schedules) for a trial site', async () => { + it('ensurePromptSuggestionSchedules submits one-time runs (no schedules) for a trial site', async () => { const drsClient = buildDrsClient(); - const result = await onboardingModule.registerPromptSuggestionSchedules({ + const result = await onboardingModule.ensurePromptSuggestionSchedules({ drsClient, siteId: 'site-123', isPaying: false, log: buildLog(), say: sandbox.stub(), }); - expect(result).to.deep.equal({ completed: true }); + expect(result.allSucceeded).to.equal(true); + expect(result.results.map((r) => r.status)).to.deep.equal(['submitted', 'submitted', 'submitted']); // Trial → one-shot submitJob per pipeline, no recurring schedules. expect(drsClient.submitJob).to.have.been.calledThrice; expect(drsClient.createSchedule).to.not.have.been.called; }); - it('registerPromptSuggestionSchedules resolves to the sentinel when DRS is not configured', async () => { + it('ensurePromptSuggestionSchedules short-circuits (empty results) when DRS is not configured', async () => { const drsClient = buildDrsClient(sandbox.stub()); drsClient.isConfigured = sandbox.stub().returns(false); - const result = await onboardingModule.registerPromptSuggestionSchedules({ + const result = await onboardingModule.ensurePromptSuggestionSchedules({ drsClient, siteId: 'site-123', isPaying: true, log: buildLog(), say: sandbox.stub(), }); - expect(result).to.deep.equal({ completed: true }); + expect(result).to.deep.equal({ results: [], allSucceeded: true }); expect(drsClient.createSchedule).to.not.have.been.called; expect(drsClient.submitJob).to.not.have.been.called; }); @@ -6023,8 +6025,7 @@ describe('LLMO Onboarding Functions', () => { it('registers all three prompt-suggestion schedules after the Brandalf trigger', async () => { const mockDrsClient = createMockDrsClient(sandbox); - const { activateBrandAndGeneratePrompts } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { activateBrandAndGeneratePrompts } = await esmockOnboarding( createCommonEsmockDependencies({ mockDrsClient, mockUpsertBrand: sandbox.stub().resolves({ id: 'brand-123', name: 'Test Brand' }), @@ -6049,8 +6050,7 @@ describe('LLMO Onboarding Functions', () => { it('submits one-time runs (no schedules) for a FREE_TRIAL site', async () => { const mockDrsClient = createMockDrsClient(sandbox); - const { activateBrandAndGeneratePrompts } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { activateBrandAndGeneratePrompts } = await esmockOnboarding( createCommonEsmockDependencies({ mockDrsClient, mockTierClient: createMockTierClientForTier('FREE_TRIAL', sandbox), @@ -6084,8 +6084,7 @@ describe('LLMO Onboarding Functions', () => { checkValidEntitlement: sandbox.stub().rejects(new Error('tier lookup boom')), }), }; - const { activateBrandAndGeneratePrompts } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { activateBrandAndGeneratePrompts } = await esmockOnboarding( createCommonEsmockDependencies({ mockDrsClient, mockTierClient: failingTierClient, @@ -6111,8 +6110,7 @@ describe('LLMO Onboarding Functions', () => { checkValidEntitlement: sandbox.stub().resolves({}), }), }; - const { activateBrandAndGeneratePrompts } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { activateBrandAndGeneratePrompts } = await esmockOnboarding( createCommonEsmockDependencies({ mockDrsClient, mockTierClient: noEntitlementTierClient, @@ -6136,8 +6134,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(sandbox, { createSchedule: sandbox.stub().rejects(err), }); - const { activateBrandAndGeneratePrompts } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { activateBrandAndGeneratePrompts } = await esmockOnboarding( createCommonEsmockDependencies({ mockDrsClient, mockUpsertBrand: sandbox.stub().resolves({ id: 'brand-123', name: 'Test Brand' }), @@ -6172,8 +6169,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(sandbox, { createSchedule: sandbox.stub().rejects(err), }); - const { activateBrandAndGeneratePrompts } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { activateBrandAndGeneratePrompts } = await esmockOnboarding( createCommonEsmockDependencies({ mockDrsClient, mockUpsertBrand: sandbox.stub().resolves({ id: 'brand-123', name: 'Test Brand' }), @@ -6200,8 +6196,7 @@ describe('LLMO Onboarding Functions', () => { createSchedule.resolves({ scheduleId: 'sched-ok', alreadyExisted: false }); const mockDrsClient = createMockDrsClient(sandbox, { createSchedule }); - const { activateBrandAndGeneratePrompts } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { activateBrandAndGeneratePrompts } = await esmockOnboarding( createCommonEsmockDependencies({ mockDrsClient, mockUpsertBrand: sandbox.stub().resolves({ id: 'brand-123', name: 'Test Brand' }), @@ -6230,8 +6225,7 @@ describe('LLMO Onboarding Functions', () => { // onboarding resolves. const createFromErr = new Error('DRS client init boom'); const mockDrsClient = { createFrom: sandbox.stub().throws(createFromErr) }; - const { activateBrandAndGeneratePrompts } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { activateBrandAndGeneratePrompts } = await esmockOnboarding( createCommonEsmockDependencies({ mockDrsClient, mockUpsertBrand: sandbox.stub().resolves({ id: 'brand-123', name: 'Test Brand' }), @@ -6257,8 +6251,7 @@ describe('LLMO Onboarding Functions', () => { try { const createSchedule = sandbox.stub().returns(new Promise(() => {})); // never settles const mockDrsClient = createMockDrsClient(sandbox, { createSchedule }); - const { activateBrandAndGeneratePrompts } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { activateBrandAndGeneratePrompts } = await esmockOnboarding( createCommonEsmockDependencies({ mockDrsClient, mockUpsertBrand: sandbox.stub().resolves({ id: 'brand-123', name: 'Test Brand' }), @@ -6294,8 +6287,7 @@ describe('LLMO Onboarding Functions', () => { checkValidEntitlement: sandbox.stub().returns(new Promise(() => {})), }), }; - const { activateBrandAndGeneratePrompts } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { activateBrandAndGeneratePrompts } = await esmockOnboarding( createCommonEsmockDependencies({ mockDrsClient, mockTierClient: hangingTierClient, @@ -6328,8 +6320,7 @@ describe('LLMO Onboarding Functions', () => { const mockDrsClient = createMockDrsClient(sandbox, { submitJob: sandbox.stub().rejects(err), }); - const { activateBrandAndGeneratePrompts } = await esmock( - '../../../src/controllers/llmo/llmo-onboarding.js', + const { activateBrandAndGeneratePrompts } = await esmockOnboarding( createCommonEsmockDependencies({ mockDrsClient, mockTierClient: createMockTierClientForTier('FREE_TRIAL', sandbox), diff --git a/test/controllers/llmo/prompt-suggestion-schedules.test.js b/test/controllers/llmo/prompt-suggestion-schedules.test.js new file mode 100644 index 0000000000..3b4591aa85 --- /dev/null +++ b/test/controllers/llmo/prompt-suggestion-schedules.test.js @@ -0,0 +1,216 @@ +/* + * 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 { expect } from 'chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +const SITE_ID = '48656b02-62cb-46c0-b3fe-4a1a0f0c3b2f'; + +describe('PromptSuggestionSchedulesController', () => { + let sandbox; + let Controller; + let mockAccessControlUtil; + let mockDrsClient; + let ensurePromptSuggestionSchedules; + let isPayingLlmoSite; + let mockContext; + let mockSite; + + before(async () => { + Controller = (await esmock('../../../src/controllers/llmo/prompt-suggestion-schedules.js', { + '../../../src/support/access-control-util.js': { + default: { fromContext: () => mockAccessControlUtil }, + }, + '@adobe/spacecat-shared-drs-client': { + default: { createFrom: () => mockDrsClient }, + }, + '../../../src/support/prompt-suggestion-schedules.js': { + ensurePromptSuggestionSchedules: (...args) => ensurePromptSuggestionSchedules(...args), + isPayingLlmoSite: (...args) => isPayingLlmoSite(...args), + }, + })).default; + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + mockSite = { getId: sandbox.stub().returns(SITE_ID) }; + + mockAccessControlUtil = { + hasAdminAccess: sandbox.stub().returns(true), + hasS2SCapability: sandbox.stub().resolves({ allowed: false, reason: 'no-capability' }), + }; + + mockDrsClient = { isConfigured: sandbox.stub().returns(true) }; + + isPayingLlmoSite = sandbox.stub().resolves(true); + ensurePromptSuggestionSchedules = sandbox.stub().resolves({ + results: [ + { providerId: 'prompt_generation_semrush', status: 'created' }, + { providerId: 'prompt_generation_agentic_traffic', status: 'already-existed' }, + { providerId: 'prompt_generation_synthetic_personas', status: 'created' }, + ], + allSucceeded: true, + }); + + mockContext = { + log: { + info: sandbox.stub(), warn: sandbox.stub(), error: sandbox.stub(), debug: sandbox.stub(), + }, + params: { siteId: SITE_ID }, + invocation: { id: 'req-1' }, + dataAccess: { + Site: { findById: sandbox.stub().resolves(mockSite) }, + }, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + const build = () => Controller(mockContext); + + it('throws without a context', () => { + expect(() => Controller(undefined)).to.throw('Context required'); + }); + + it('throws without dataAccess', () => { + expect(() => Controller({ log: {} })).to.throw('Data access required'); + }); + + describe('authorization', () => { + it('denies a non-admin caller without the S2S capability (403)', async () => { + mockAccessControlUtil.hasAdminAccess.returns(false); + mockAccessControlUtil.hasS2SCapability.resolves({ allowed: false, reason: 'no-capability' }); + + const res = await build().createSchedules(mockContext); + + expect(res.status).to.equal(403); + expect(ensurePromptSuggestionSchedules).to.not.have.been.called; + }); + + it('allows an admin caller (bypasses the S2S capability check)', async () => { + mockAccessControlUtil.hasAdminAccess.returns(true); + + const res = await build().createSchedules(mockContext); + + expect(res.status).to.equal(200); + expect(mockAccessControlUtil.hasS2SCapability).to.not.have.been.called; + }); + + it('allows a non-admin S2S consumer holding the capability', async () => { + mockAccessControlUtil.hasAdminAccess.returns(false); + mockAccessControlUtil.hasS2SCapability.resolves({ allowed: true, clientId: 'client-9' }); + + const res = await build().createSchedules(mockContext); + + expect(res.status).to.equal(200); + expect(mockAccessControlUtil.hasS2SCapability).to.have.been.calledOnce; + }); + }); + + describe('validation', () => { + it('returns 400 for an invalid siteId', async () => { + mockContext.params.siteId = 'not-a-uuid'; + const res = await build().createSchedules(mockContext); + expect(res.status).to.equal(400); + expect(mockContext.dataAccess.Site.findById).to.not.have.been.called; + }); + + it('returns 404 for an unknown site', async () => { + mockContext.dataAccess.Site.findById.resolves(null); + const res = await build().createSchedules(mockContext); + expect(res.status).to.equal(404); + expect(ensurePromptSuggestionSchedules).to.not.have.been.called; + }); + }); + + describe('tier gate', () => { + it('provisions recurring schedules for a paying site and returns per-pipeline results', async () => { + const res = await build().createSchedules(mockContext); + const body = await res.json(); + + expect(res.status).to.equal(200); + expect(ensurePromptSuggestionSchedules).to.have.been.calledOnce; + expect(ensurePromptSuggestionSchedules.firstCall.args[0]).to.include({ + siteId: SITE_ID, + isPaying: true, + }); + expect(body.isPaying).to.equal(true); + expect(body.skipped).to.equal(false); + expect(body.allSucceeded).to.equal(true); + expect(body.results).to.have.length(3); + }); + + it('skips a non-paying site without creating any schedule', async () => { + isPayingLlmoSite.resolves(false); + const res = await build().createSchedules(mockContext); + const body = await res.json(); + + expect(res.status).to.equal(200); + expect(body.skipped).to.equal(true); + expect(body.isPaying).to.equal(false); + expect(body.reason).to.equal('not-paying'); + expect(ensurePromptSuggestionSchedules).to.not.have.been.called; + }); + + it('ignores a caller-supplied isPaying=true on a trial site (tier re-derived server-side)', async () => { + // Server says trial; body says paying → must skip (no recurring schedule). + isPayingLlmoSite.resolves(false); + mockContext.data = { isPaying: true, tier: 'paid' }; + + const res = await build().createSchedules(mockContext); + const body = await res.json(); + + expect(body.skipped).to.equal(true); + expect(body.isPaying).to.equal(false); + expect(ensurePromptSuggestionSchedules).to.not.have.been.called; + }); + + it('surfaces a per-pipeline failure (allSucceeded=false) with the failed provider', async () => { + ensurePromptSuggestionSchedules.resolves({ + results: [ + { providerId: 'prompt_generation_semrush', status: 'created' }, + { providerId: 'prompt_generation_agentic_traffic', status: 'failed', error: 'DRS 500' }, + { providerId: 'prompt_generation_synthetic_personas', status: 'already-existed' }, + ], + allSucceeded: false, + }); + + const res = await build().createSchedules(mockContext); + const body = await res.json(); + + expect(res.status).to.equal(200); + expect(body.allSucceeded).to.equal(false); + const failed = body.results.find((r) => r.status === 'failed'); + expect(failed.providerId).to.equal('prompt_generation_agentic_traffic'); + expect(failed.error).to.equal('DRS 500'); + }); + }); + + describe('DRS availability', () => { + it('returns 500 when the DRS client is not configured', async () => { + mockDrsClient.isConfigured.returns(false); + const res = await build().createSchedules(mockContext); + expect(res.status).to.equal(500); + expect(ensurePromptSuggestionSchedules).to.not.have.been.called; + }); + }); + + it('returns 500 when Site lookup throws', async () => { + mockContext.dataAccess.Site.findById.rejects(new Error('db down')); + const res = await build().createSchedules(mockContext); + expect(res.status).to.equal(500); + }); +}); diff --git a/test/routes/capability-constants.test.js b/test/routes/capability-constants.test.js index 835360b2a4..074280f8a9 100644 --- a/test/routes/capability-constants.test.js +++ b/test/routes/capability-constants.test.js @@ -89,6 +89,7 @@ describe('capability-constants drift coverage', () => { join(projectRoot, 'src/controllers/suggestions.js'), join(projectRoot, 'src/controllers/configuration.js'), join(projectRoot, 'src/controllers/trial-users.js'), + join(projectRoot, 'src/controllers/llmo/prompt-suggestion-schedules.js'), ]; const controllerSource = controllerFiles .map((file) => readFileSync(file, 'utf8')) diff --git a/test/routes/index.test.js b/test/routes/index.test.js index b8381a31f9..cafd7eab07 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -620,6 +620,10 @@ describe('getRouteHandlers', () => { getRedirects: sinon.stub(), }; + const mockPromptSuggestionSchedulesController = { + createSchedules: sinon.stub(), + }; + it('segregates static and dynamic routes', () => { const { staticRoutes, dynamicRoutes } = getRouteHandlers( mockAuditsController, @@ -687,6 +691,7 @@ describe('getRouteHandlers', () => { mockProxyController, mockTaskManagementController, mockRedirectsController, + mockPromptSuggestionSchedulesController, ); expect(staticRoutes).to.have.all.keys( @@ -1178,6 +1183,7 @@ describe('getRouteHandlers', () => { 'GET /sites/:siteId/llmo/strategy/demo/brand-presence', 'GET /sites/:siteId/llmo/strategy/demo/recommendations', 'POST /sites/:siteId/llmo/offboard', + 'POST /sites/:siteId/prompt-suggestion-schedules', 'POST /v2/orgs/:spaceCatId/llmo/onboard-site', 'POST /sites/:siteId/llmo/edge-optimize-config', 'GET /sites/:siteId/llmo/edge-optimize-config', @@ -1529,6 +1535,8 @@ describe('getRouteHandlers', () => { expect(dynamicRoutes['PATCH /sites/:siteId/llmo/customer-intent/:intentKey'].paramNames).to.deep.equal(['siteId', 'intentKey']); expect(dynamicRoutes['POST /sites/:siteId/llmo/offboard'].handler).to.equal(mockLlmoController.offboardCustomer); expect(dynamicRoutes['POST /sites/:siteId/llmo/offboard'].paramNames).to.deep.equal(['siteId']); + expect(dynamicRoutes['POST /sites/:siteId/prompt-suggestion-schedules'].handler).to.equal(mockPromptSuggestionSchedulesController.createSchedules); + expect(dynamicRoutes['POST /sites/:siteId/prompt-suggestion-schedules'].paramNames).to.deep.equal(['siteId']); expect(dynamicRoutes['POST /v2/orgs/:spaceCatId/llmo/onboard-site'].handler).to.equal(mockLlmoController.onboardSiteOnly); expect(dynamicRoutes['POST /v2/orgs/:spaceCatId/llmo/onboard-site'].paramNames).to.deep.equal(['spaceCatId']); expect(dynamicRoutes['POST /sites/:siteId/llmo/edge-optimize-config'].handler).to.equal(mockLlmoController.createOrUpdateEdgeConfig); diff --git a/test/support/prompt-suggestion-schedules.test.js b/test/support/prompt-suggestion-schedules.test.js new file mode 100644 index 0000000000..c4ec47b001 --- /dev/null +++ b/test/support/prompt-suggestion-schedules.test.js @@ -0,0 +1,178 @@ +/* + * 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 { expect } from 'chai'; +import sinon from 'sinon'; +import esmock from 'esmock'; +import { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access/src/models/entitlement/index.js'; + +const SITE_ID = 'site-abc'; + +describe('prompt-suggestion-schedules support module', () => { + let sandbox; + let module; + let tierClientStub; + + before(async () => { + module = await esmock('../../src/support/prompt-suggestion-schedules.js', { + '@adobe/spacecat-shared-tier-client': { + default: { createForSite: async () => tierClientStub }, + }, + }); + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + const buildLog = () => ({ + info: sandbox.stub(), debug: sandbox.stub(), warn: sandbox.stub(), error: sandbox.stub(), + }); + + const buildDrsClient = (overrides = {}) => ({ + isConfigured: sandbox.stub().returns(true), + createSchedule: sandbox.stub().resolves({ scheduleId: 'sched-1', alreadyExisted: false }), + submitJob: sandbox.stub().resolves({ job_id: 'job-1' }), + ...overrides, + }); + + describe('PROMPT_SUGGESTION_PIPELINES', () => { + it('declares each provider+cadence exactly once', () => { + expect(module.PROMPT_SUGGESTION_PIPELINES.map( + ({ providerId, cadence }) => [providerId, cadence], + )).to.deep.equal([ + ['prompt_generation_semrush', 'twice_monthly'], + ['prompt_generation_agentic_traffic', 'twice_monthly'], + ['prompt_generation_synthetic_personas', 'quarterly'], + ]); + }); + }); + + describe('ensurePromptSuggestionSchedules', () => { + it('creates a recurring schedule per pipeline (paying) and reports created', async () => { + const drsClient = buildDrsClient(); + const { results, allSucceeded } = await module.ensurePromptSuggestionSchedules({ + drsClient, siteId: SITE_ID, isPaying: true, log: buildLog(), + }); + + expect(allSucceeded).to.equal(true); + expect(drsClient.createSchedule).to.have.been.calledThrice; + expect(drsClient.submitJob).to.not.have.been.called; + expect(results.map((r) => r.status)).to.deep.equal(['created', 'created', 'created']); + }); + + it('reports already-existed as success (idempotent)', async () => { + const drsClient = buildDrsClient({ + createSchedule: sandbox.stub().resolves({ scheduleId: 's', alreadyExisted: true }), + }); + const { results, allSucceeded } = await module.ensurePromptSuggestionSchedules({ + drsClient, siteId: SITE_ID, isPaying: true, log: buildLog(), + }); + + expect(allSucceeded).to.equal(true); + expect(results.every((r) => r.status === 'already-existed')).to.equal(true); + }); + + it('submits a one-shot run per pipeline (trial) and reports submitted', async () => { + const drsClient = buildDrsClient(); + const { results, allSucceeded } = await module.ensurePromptSuggestionSchedules({ + drsClient, siteId: SITE_ID, isPaying: false, log: buildLog(), + }); + + expect(allSucceeded).to.equal(true); + expect(drsClient.submitJob).to.have.been.calledThrice; + expect(drsClient.createSchedule).to.not.have.been.called; + expect(results.map((r) => r.status)).to.deep.equal(['submitted', 'submitted', 'submitted']); + }); + + it('surfaces a single pipeline failure without masking the others (allSucceeded=false)', async () => { + const err = new Error('DRS POST /schedules failed: 500'); + err.status = 500; + const createSchedule = sandbox.stub(); + // First pipeline fails, the rest succeed. + createSchedule.onCall(0).rejects(err); + createSchedule.resolves({ scheduleId: 's', alreadyExisted: false }); + const drsClient = buildDrsClient({ createSchedule }); + const log = buildLog(); + + const { results, allSucceeded } = await module.ensurePromptSuggestionSchedules({ + drsClient, siteId: SITE_ID, isPaying: true, log, + }); + + expect(allSucceeded).to.equal(false); + const failed = results.filter((r) => r.status === 'failed'); + expect(failed).to.have.length(1); + expect(failed[0].providerId).to.equal('prompt_generation_semrush'); + expect(failed[0].error).to.equal('DRS POST /schedules failed: 500'); + // The other two still succeeded. + expect(results.filter((r) => r.status === 'created')).to.have.length(2); + expect(log.error).to.have.been.calledWithMatch(/status=500/); + }); + + it('short-circuits to empty results when DRS is not configured', async () => { + const drsClient = buildDrsClient({ isConfigured: sandbox.stub().returns(false) }); + const result = await module.ensurePromptSuggestionSchedules({ + drsClient, siteId: SITE_ID, isPaying: true, log: buildLog(), + }); + expect(result).to.deep.equal({ results: [], allSucceeded: true }); + expect(drsClient.createSchedule).to.not.have.been.called; + }); + }); + + describe('isPayingLlmoSite', () => { + const buildSite = () => ({ getId: sandbox.stub().returns(SITE_ID) }); + + it('returns true when the current LLMO tier is PAID', async () => { + tierClientStub = { + checkValidEntitlement: sandbox.stub().resolves({ + entitlement: { getTier: () => EntitlementModel.TIERS.PAID }, + }), + }; + const result = await module.isPayingLlmoSite(buildSite(), { log: buildLog() }); + expect(result).to.equal(true); + }); + + it('returns false for a non-PAID tier', async () => { + tierClientStub = { + checkValidEntitlement: sandbox.stub().resolves({ + entitlement: { getTier: () => EntitlementModel.TIERS.FREE_TRIAL }, + }), + }; + const result = await module.isPayingLlmoSite(buildSite(), { log: buildLog() }); + expect(result).to.equal(false); + }); + + it('fails safe to false (with a warning) when no entitlement is found', async () => { + tierClientStub = { + checkValidEntitlement: sandbox.stub().resolves({ entitlement: null }), + }; + const log = buildLog(); + const result = await module.isPayingLlmoSite(buildSite(), { log }); + expect(result).to.equal(false); + expect(log.warn).to.have.been.calledWithMatch(/no entitlement found/); + }); + + it('fails safe to false (with a warning) when the lookup throws', async () => { + tierClientStub = { + checkValidEntitlement: sandbox.stub().rejects(new Error('tier service down')), + }; + const log = buildLog(); + const result = await module.isPayingLlmoSite(buildSite(), { log }); + expect(result).to.equal(false); + expect(log.warn).to.have.been.calledWithMatch(/tier service down/); + }); + }); +});