diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c6047209..6e1e6bdb43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.644.0](https://github.com/adobe/spacecat-api-service/compare/v1.643.1...v1.644.0) (2026-07-13) + + +### Features + +* **ai-visibility:** server-side sort for brand list endpoints ([#2792](https://github.com/adobe/spacecat-api-service/issues/2792)) ([210627c](https://github.com/adobe/spacecat-api-service/commit/210627c1e01734f40a10d1bcf383d56b4ce092b4)) + ## [1.643.1](https://github.com/adobe/spacecat-api-service/compare/v1.643.0...v1.643.1) (2026-07-13) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index b7d4a25e15..f96bb26c00 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -195,6 +195,22 @@ paths: $ref: './organization-api.yaml#/organization-by-ims-org-id' /organizations/by-ims-org-id/{imsOrgId}/slack-config: $ref: './organization-api.yaml#/organization-slack-config-by-ims-org-id' + /organizations/{organizationId}/task-management/connections: + $ref: './task-management-api.yaml#/task-management-connections' + /organizations/{organizationId}/task-management/connections/{connectionId}: + $ref: './task-management-api.yaml#/task-management-connection-by-id' + /organizations/{organizationId}/task-management/connections/{connectionId}/projects: + $ref: './task-management-api.yaml#/task-management-connection-projects' + /organizations/{organizationId}/task-management/connections/{connectionId}/issue-types: + $ref: './task-management-api.yaml#/task-management-connection-issue-types' + /organizations/{organizationId}/task-management/tickets: + $ref: './task-management-api.yaml#/task-management-tickets' + /organizations/{organizationId}/task-management/{provider}/tickets: + $ref: './task-management-api.yaml#/task-management-create-ticket' + /organizations/{organizationId}/suggestions/{suggestionId}/ticket: + $ref: './task-management-api.yaml#/ticket-by-suggestion' + /organizations/{organizationId}/opportunities/{opportunityId}/tickets: + $ref: './task-management-api.yaml#/tickets-by-opportunity' /organizations/{organizationId}/projects: $ref: './organizations-api.yaml#/organization-projects' /organizations/{organizationId}/projects/{projectId}/sites: diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index 12ed647989..ee55ef5f1b 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -12409,3 +12409,120 @@ StateGrantedCapability: example: llmo/can_view pattern: '^[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*$' +# ── Task Management schemas ──────────────────────────────────────────────────── + +TaskManagementConnection: + type: object + properties: + id: + $ref: '#/Id' + organizationId: + $ref: '#/Id' + provider: + type: string + enum: [jira_cloud] + status: + type: string + enum: [active, requires_reauth, revoked] + displayName: + type: string + instanceUrl: + type: string + format: uri + connectedBy: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + +Ticket: + type: object + properties: + id: + $ref: '#/Id' + organizationId: + $ref: '#/Id' + connectionId: + $ref: '#/Id' + opportunityId: + type: string + ticketKey: + type: string + example: 'PROJ-42' + ticketUrl: + type: string + format: uri + ticketStatus: + type: string + ticketProvider: + type: string + enum: [jira_cloud] + suggestions: + type: array + items: + type: string + description: Logical suggestion IDs linked to this ticket + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + +TicketAttachment: + type: object + required: [content, mimeType, filename] + properties: + content: + type: string + format: byte + description: Base64-encoded file content + mimeType: + type: string + enum: [image/png, image/jpeg, image/gif, image/webp, application/pdf, text/csv, text/plain] + filename: + type: string + maxLength: 255 + +TicketCreateRequest: + type: object + required: [connectionId, projectKey, summary] + properties: + connectionId: + $ref: '#/Id' + projectKey: + type: string + projectId: + type: string + description: Numeric Jira project ID + issueTypeId: + type: string + summary: + type: string + maxLength: 255 + description: + type: string + labels: + type: array + items: + type: string + mode: + type: string + enum: [individual, grouped] + default: individual + suggestionIds: + type: array + items: + type: string + maxItems: 1500 + opportunityId: + type: string + attachments: + type: array + items: + $ref: '#/TicketAttachment' + maxItems: 10 + diff --git a/docs/openapi/task-management-api.yaml b/docs/openapi/task-management-api.yaml new file mode 100644 index 0000000000..129c0e49e6 --- /dev/null +++ b/docs/openapi/task-management-api.yaml @@ -0,0 +1,367 @@ +task-management-connections: + get: + tags: + - task-management + summary: List task-management connections for an organization + description: | + Returns all Jira connections configured for the given organization. + Optionally filter by provider using the `?provider=` query parameter. + operationId: listTaskManagementConnections + parameters: + - name: organizationId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + - name: provider + in: query + required: false + schema: + type: string + enum: [jira_cloud] + responses: + '200': + description: List of connections + content: + application/json: + schema: + type: array + items: + $ref: './schemas.yaml#/TaskManagementConnection' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + security: + - api_key: [] + +task-management-connection-by-id: + get: + tags: + - task-management + summary: Get a task-management connection by ID + operationId: getTaskManagementConnection + parameters: + - name: organizationId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + - name: connectionId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + responses: + '200': + description: Connection object + content: + application/json: + schema: + $ref: './schemas.yaml#/TaskManagementConnection' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + security: + - api_key: [] + +task-management-connection-projects: + get: + tags: + - task-management + summary: List Jira projects for a connection + description: | + Returns all Jira projects accessible via the given connection. + Used by the UI project picker when creating a ticket. + operationId: listTaskManagementProjects + parameters: + - name: organizationId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + - name: connectionId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + responses: + '200': + description: List of Jira projects + content: + application/json: + schema: + type: object + properties: + projects: + type: array + items: + type: object + properties: + id: + type: string + key: + type: string + name: + type: string + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + security: + - api_key: [] + +task-management-connection-issue-types: + get: + tags: + - task-management + summary: List Jira issue types for a project + description: | + Returns non-subtask issue types for the given Jira project. + `projectId` is the numeric Jira project ID (returned by the projects endpoint). + operationId: listTaskManagementIssueTypes + parameters: + - name: organizationId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + - name: connectionId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + - name: projectId + in: query + required: true + schema: + type: string + pattern: '^\d+$' + description: Numeric Jira project ID + responses: + '200': + description: List of issue types + content: + application/json: + schema: + type: object + properties: + issueTypes: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + subtask: + type: boolean + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + security: + - api_key: [] + +task-management-tickets: + get: + tags: + - task-management + summary: List tickets for an organization + operationId: listTickets + parameters: + - name: organizationId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + responses: + '200': + description: List of tickets + content: + application/json: + schema: + type: array + items: + $ref: './schemas.yaml#/Ticket' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + security: + - api_key: [] + +task-management-create-ticket: + post: + tags: + - task-management + summary: Create a Jira ticket + description: | + Creates one or more Jira tickets for an opportunity or suggestion set. + + **Modes:** + - `individual` (default): one ticket per suggestion (up to 15). Returns 207 Multi-Status when N > 1. + - `grouped`: all suggestions collapsed into a single ticket (up to 1500). Returns 201. + + Requires an `Idempotency-Key` header derived server-side from opportunityId + suggestionIds. + operationId: createTicket + parameters: + - name: organizationId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + - name: provider + in: path + required: true + schema: + type: string + enum: [jira_cloud] + requestBody: + required: true + content: + application/json: + schema: + $ref: './schemas.yaml#/TicketCreateRequest' + responses: + '201': + description: Ticket created (single or grouped mode) + content: + application/json: + schema: + $ref: './schemas.yaml#/Ticket' + '207': + description: Multi-status — individual batch result per suggestion + content: + application/json: + schema: + type: object + properties: + mode: + type: string + results: + type: array + items: + type: object + properties: + suggestionId: + type: string + status: + type: integer + ticket: + $ref: './schemas.yaml#/Ticket' + error: + type: string + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '409': + description: Duplicate request (idempotency) or connection requires re-authorization + content: + application/json: + schema: + type: object + properties: + message: + type: string + security: + - api_key: [] + +ticket-by-suggestion: + get: + tags: + - task-management + summary: Get the ticket linked to a suggestion + operationId: getTicketBySuggestion + parameters: + - name: organizationId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + - name: suggestionId + in: path + required: true + schema: + type: string + responses: + '200': + description: Ticket linked to the suggestion + content: + application/json: + schema: + $ref: './schemas.yaml#/Ticket' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + security: + - api_key: [] + +tickets-by-opportunity: + get: + tags: + - task-management + summary: List tickets for an opportunity + operationId: listTicketsByOpportunity + parameters: + - name: organizationId + in: path + required: true + schema: + $ref: './schemas.yaml#/Id' + - name: opportunityId + in: path + required: true + schema: + type: string + responses: + '200': + description: List of tickets for the opportunity + content: + application/json: + schema: + type: array + items: + $ref: './schemas.yaml#/Ticket' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + security: + - api_key: [] diff --git a/package-lock.json b/package-lock.json index 6a6e0d56c5..93fee2b782 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@adobe/spacecat-api-service", - "version": "1.643.1", + "version": "1.644.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@adobe/spacecat-api-service", - "version": "1.643.1", + "version": "1.644.0", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "4.3.0", @@ -23,7 +23,7 @@ "@adobe/spacecat-shared-brand-client": "1.4.0", "@adobe/spacecat-shared-cloudflare-client": "1.2.1", "@adobe/spacecat-shared-content-client": "1.8.24", - "@adobe/spacecat-shared-data-access": "4.6.0", + "@adobe/spacecat-shared-data-access": "4.7.0", "@adobe/spacecat-shared-drs-client": "1.13.0", "@adobe/spacecat-shared-gpt-client": "1.6.23", "@adobe/spacecat-shared-http-utils": "1.33.1", @@ -33,6 +33,7 @@ "@adobe/spacecat-shared-rum-api-client": "2.44.0", "@adobe/spacecat-shared-scrape-client": "2.6.3", "@adobe/spacecat-shared-slack-client": "1.6.7", + "@adobe/spacecat-shared-ticket-client": "^1.0.4", "@adobe/spacecat-shared-tier-client": "1.5.1", "@adobe/spacecat-shared-tokowaka-client": "1.20.0", "@adobe/spacecat-shared-user-manager-client": "1.3.1", @@ -4168,9 +4169,9 @@ } }, "node_modules/@adobe/spacecat-shared-data-access": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-data-access/-/spacecat-shared-data-access-4.6.0.tgz", - "integrity": "sha512-/Q8wKNoyQ6Pyn15ka6cvI+GmeAl3Z2tm3NjvKQbtVPgl0yZElfmuDt/uRMK/EhthQbXnL/o9ZQLAvzg8oz7UBw==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-data-access/-/spacecat-shared-data-access-4.7.0.tgz", + "integrity": "sha512-L0e0SEJZz/nPLFXBA3KH4ZZkPpBthCjk6nFHBI4EQXpXw0SbaUgh/UpLlfcYTf79q2Py0csqpn9pDUBDHuJOgQ==", "license": "Apache-2.0", "dependencies": { "@adobe/fetch": "^4.2.3", @@ -8976,6 +8977,31 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@adobe/spacecat-shared-ticket-client": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-ticket-client/-/spacecat-shared-ticket-client-1.0.4.tgz", + "integrity": "sha512-EZrf/X2oacFfN7b3unVwoZzDE756Z1jbVjNXuZPqbMrAPCHmdws+enRaDXPoirDBbThtl9nlncxa+2kAXZxsLw==", + "license": "Apache-2.0", + "dependencies": { + "marked": "^18.0.5" + }, + "engines": { + "node": ">=22.0.0 <25.0.0", + "npm": ">=10.9.0 <12.0.0" + } + }, + "node_modules/@adobe/spacecat-shared-ticket-client/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@adobe/spacecat-shared-tier-client": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@adobe/spacecat-shared-tier-client/-/spacecat-shared-tier-client-1.5.1.tgz", diff --git a/package.json b/package.json index 72deb79827..b28cecd2e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/spacecat-api-service", - "version": "1.643.1", + "version": "1.644.0", "description": "SpaceCat API Service", "main": "src/index.js", "type": "module", @@ -88,7 +88,7 @@ "@adobe/spacecat-shared-brand-client": "1.4.0", "@adobe/spacecat-shared-cloudflare-client": "1.2.1", "@adobe/spacecat-shared-content-client": "1.8.24", - "@adobe/spacecat-shared-data-access": "4.6.0", + "@adobe/spacecat-shared-data-access": "4.7.0", "@adobe/spacecat-shared-drs-client": "1.13.0", "@adobe/spacecat-shared-gpt-client": "1.6.23", "@adobe/spacecat-shared-http-utils": "1.33.1", @@ -98,6 +98,7 @@ "@adobe/spacecat-shared-rum-api-client": "2.44.0", "@adobe/spacecat-shared-scrape-client": "2.6.3", "@adobe/spacecat-shared-slack-client": "1.6.7", + "@adobe/spacecat-shared-ticket-client": "^1.0.4", "@adobe/spacecat-shared-tier-client": "1.5.1", "@adobe/spacecat-shared-tokowaka-client": "1.20.0", "@adobe/spacecat-shared-user-manager-client": "1.3.1", diff --git a/src/controllers/task-management.js b/src/controllers/task-management.js new file mode 100644 index 0000000000..910895c0f5 --- /dev/null +++ b/src/controllers/task-management.js @@ -0,0 +1,1369 @@ +/* + * 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 { createHash } from 'node:crypto'; +import { + GetSecretValueCommand, + PutSecretValueCommand, + SecretsManagerClient, +} from '@aws-sdk/client-secrets-manager'; +import { + badRequest, + created, + createResponse, + forbidden, + internalServerError, + notFound, + ok, +} from '@adobe/spacecat-shared-http-utils'; +import { TicketClientFactory } from '@adobe/spacecat-shared-ticket-client'; +import { hasText, isNonEmptyObject, isValidUUID } from '@adobe/spacecat-shared-utils'; +import AccessControlUtil from '../support/access-control-util.js'; +import { TaskManagementConnectionDto } from '../dto/task-management-connection.js'; +import { TicketDto } from '../dto/ticket.js'; + +// Accepts any well-formed UUID (relaxed: any hex in version/variant positions). +// The gateway's isValidUUIDAnyVersion enforces this pattern upstream, so only +// structurally valid UUIDs reach the controller. +const UUID_ANY_VERSION_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const isValidUUIDAnyVersion = (v) => UUID_ANY_VERSION_RE.test(v); + +const STATUS_CREATED = 201; +const STATUS_NOT_FOUND = 404; +const STATUS_INTERNAL_SERVER_ERROR = 500; +const STATUS_CONFLICT = 409; +const STATUS_MULTI_STATUS = 207; + +const SUPPORTED_PROVIDERS = new Set(['jira_cloud']); + +// Ticket creation modes. +// 'individual': one ticket per suggestion (N→N). N>1 returns 207 Multi-Status. +// 'grouped': all suggestions into a single ticket (M→1). Returns 201. +const TICKET_MODE_INDIVIDUAL = 'individual'; +const TICKET_MODE_GROUPED = 'grouped'; +// individual: one ticket per suggestion (N→N), grouped: all suggestions into one ticket (M→1) +// INDIVIDUAL cap is Jira-bound: 15 × ~800ms/ticket ≈ 12s < 15s Lambda timeout. +// GROUPED cap: suggestions validated and bridge rows created in chunked parallel (20 at a time), +// then Jira creates 1 ticket — well within Lambda timeout budget. +const SUGGESTION_IDS_MAX_INDIVIDUAL = 15; +const SUGGESTION_IDS_MAX_GROUPED = 1500; +const ATTACHMENT_MAX_BYTES = 3 * 1024 * 1024; // 3 MB per spec §30 +// Must stay in sync with ATTACHMENT_ALLOWED_MIME_TYPES in jira-cloud-client.js +const ALLOWED_ATTACHMENT_MIME_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/csv', + 'text/plain', +]); + +/** + * TaskManagementController — manages Jira connections and tickets for an organization. + * + * Routes: + * GET /organizations/:organizationId/task-management/connections + * GET /organizations/:organizationId/task-management/connections/:connectionId + * GET /organizations/:organizationId/task-management/tickets + * GET /organizations/:organizationId/suggestions/:suggestionId/ticket + * GET /organizations/:organizationId/opportunities/:opportunityId/tickets + * POST /organizations/:organizationId/task-management/:provider/tickets + * GET /organizations/:organizationId/task-management/:provider/projects + * + * v1 scope — intentional deviations from the architecture spec (PR #150): + * - Ticket creation modes (individual + grouped) are BOTH implemented in v1 + * contrary to the original scope which planned grouped as v2. The architecture + * reviewers asked for clarity on multi-suggestion semantics; the implementation + * resolves this by shipping both modes with explicit caps (individual: ≤15, + * grouped: ≤1500) and full idempotency enforcement. + * - Attachment upload is included inline in POST /tickets (v1). The spec discussed + * attachments as an optional feature; they are implemented with a partial-success + * model: ticket creation succeeds even when attachment upload fails, with a + * warning in the response body. + * - Idempotency-Key header is enforced in v1 (not deferred). The idempotency_keys + * table (DB PR #720) is used with a 2-minute TTL (in-flight lock window only; + * bridge-row presence is the permanent deduplication guard after expiry). + * Status machine: processing → completed | failed. Duplicate requests return + * the cached response. + * - connectionId in POST body: required. Caller must specify which connection to use. + * - Ticket summary and description come from the request body (client-provided). + * Spec §7 step 5 shows the server building the ADF description server-side from + * Suggestion/Opportunity data. In v1, the ASO UI sends summary + description + * directly; the server wraps them in ADF via JiraCloudClient.buildAdfDescription. + * Server-side description templating from Suggestion data is deferred to v2. + * - List endpoints have no pagination in v1 (volume negligible at current scale). + * + * @param {object} context - Universal serverless function context. + * @param {object} context.dataAccess - Data access layer (models). + * @param {import('pino').Logger} context.log - Logger. + * @returns {object} Controller with connection and ticket methods. + */ +function TaskManagementController(context) { + if (!isNonEmptyObject(context)) { + throw new Error('Context required'); + } + + const { dataAccess, log } = context; + + if (!isNonEmptyObject(dataAccess)) { + throw new Error('Data access required'); + } + + const { + Organization, TaskManagementConnection, Ticket, TicketSuggestion, Suggestion, IdempotencyKey, + } = dataAccess; + + if (!isNonEmptyObject(TaskManagementConnection)) { + throw new Error('TaskManagementConnection collection not available'); + } + + if (!isNonEmptyObject(Ticket)) { + throw new Error('Ticket collection not available'); + } + + if (!isNonEmptyObject(TicketSuggestion)) { + throw new Error('TicketSuggestion collection not available'); + } + + if (!isNonEmptyObject(Suggestion)) { + throw new Error('Suggestion collection not available'); + } + + if (!isNonEmptyObject(Organization)) { + throw new Error('Organization collection not available'); + } + + if (!isNonEmptyObject(IdempotencyKey)) { + throw new Error('IdempotencyKey collection not available'); + } + + // SecretsManagerClient is constructed here for v1 simplicity. + // ticket-client's OAuthCredentialManager requires .getSecretValue / .putSecretValue. + const rawSmClient = new SecretsManagerClient(); + const smClient = { + getSecretValue: (params) => rawSmClient.send(new GetSecretValueCommand(params)), + putSecretValue: (params) => rawSmClient.send(new PutSecretValueCommand(params)), + }; + + // Wrap global fetch so TicketClientFactory receives the expected { fetch } interface. + // fetch is available globally in Node 18+ (Lambda runtime). + const httpClient = { fetch: globalThis.fetch }; + + const accessControlUtil = AccessControlUtil.fromContext(context); + + // ─── Helpers ────────────────────────────────────────────────────────────── + + /** + * Loads an organization by ID and verifies the caller has access to it. + * Returns { denied: Response } when not found or forbidden, { org } on success. + */ + async function loadOrgWithAccess(organizationId) { + const org = await Organization.findById(organizationId); + if (!org) { + return { denied: notFound('Organization not found') }; + } + if (!await accessControlUtil.hasAccess(org)) { + return { denied: forbidden('Forbidden') }; + } + return { org }; + } + + /** + * Loads a connection by ID and verifies it belongs to the given organization. + * Returns null when not found or org mismatch (both map to 404 to avoid + * leaking whether a connectionId exists in a different org). + */ + async function loadConnectionForOrg(organizationId, connectionId) { + const conn = await TaskManagementConnection.findById(connectionId); + if (!conn || conn.getOrganizationId() !== organizationId) { + return null; + } + return conn; + } + + /** + * Constructs a TicketClient for the given connection. + */ + function buildTicketClient(connection) { + const connectionObj = { + id: connection.getId(), + organizationId: connection.getOrganizationId(), + provider: connection.getProvider(), + // instanceUrl is required by TicketClientFactory — it merges it into config as siteUrl + // for the JiraCloudClient SSRF-safe gateway URL construction. + instanceUrl: connection.getInstanceUrl(), + metadata: connection.getMetadata(), + }; + return TicketClientFactory.create(connectionObj, smClient, httpClient, log); + } + + /** + * Classifies a provider API error into one of two auth-failure categories. + * Returns { isGrantRevoked, isTokenExpired }. + */ + function classifyProviderError(err) { + const isGrantRevoked = err.code === 'GRANT_REVOKED' + || err.code === 'REQUIRES_REAUTH' + || err.message?.includes('requires re-authorization'); + const isTokenExpired = err.status === 401 + || err.code === 'TOKEN_REFRESH_REQUIRED'; + return { isGrantRevoked, isTokenExpired }; + } + + // ─── Connection handlers ─────────────────────────────────────────────────── + + /** + * Lists all task-management connections for an organization. + * + * GET /organizations/:organizationId/task-management/connections + * Query: ?provider= (optional filter) + */ + async function listConnections(requestContext) { + const { params } = requestContext; + const qs = Object.fromEntries(new URL(requestContext.request.url).searchParams); + const { organizationId } = params; + + if (!isValidUUID(organizationId)) { + return badRequest('organizationId must be a valid UUID'); + } + + const { denied } = await loadOrgWithAccess(organizationId); + if (denied) { + return denied; + } + + let connections; + try { + connections = await TaskManagementConnection.allByOrganizationId(organizationId); + } catch (err) { + log.error({ organizationId, err }, 'Failed to list task-management connections'); + return internalServerError('Failed to list connections'); + } + + const filtered = qs?.provider + ? connections.filter((c) => c.getProvider() === qs.provider) + : connections; + + return ok(filtered.map(TaskManagementConnectionDto.toJSON)); + } + + /** + * Returns a single task-management connection. + * + * GET /organizations/:organizationId/task-management/connections/:connectionId + */ + async function getConnection(requestContext) { + const { params } = requestContext; + const { organizationId, connectionId } = params; + + if (!isValidUUID(organizationId)) { + return badRequest('organizationId must be a valid UUID'); + } + + if (!isValidUUIDAnyVersion(connectionId)) { + return badRequest('connectionId must be a valid UUID'); + } + + const { denied } = await loadOrgWithAccess(organizationId); + if (denied) { + return denied; + } + + let connection; + try { + connection = await loadConnectionForOrg(organizationId, connectionId); + } catch (err) { + log.error({ organizationId, connectionId, err }, 'Failed to load task-management connection'); + return internalServerError('Failed to load connection'); + } + + if (!connection) { + return notFound(`Connection ${connectionId} not found`); + } + + return ok(TaskManagementConnectionDto.toJSON(connection)); + } + + // ─── Ticket read handlers ────────────────────────────────────────────────── + + /** + * Lists all tickets created for an organization. + * + * GET /organizations/:organizationId/task-management/tickets + * + * Response shape: array of ticket objects with `suggestions` bridge array. + */ + async function listTickets(requestContext) { + const { params } = requestContext; + const { organizationId } = params; + + if (!isValidUUID(organizationId)) { + return badRequest('organizationId must be a valid UUID'); + } + + const { denied } = await loadOrgWithAccess(organizationId); + if (denied) { + return denied; + } + + let tickets; + try { + tickets = await Ticket.allByOrganizationId(organizationId); + } catch (err) { + log.error({ organizationId, err }, 'Failed to list tickets'); + return internalServerError('Failed to list tickets'); + } + + // Bulk-load all bridge rows for the org's tickets in one query, then group by ticket ID. + const bridgeMap = new Map(); + if (tickets.length > 0) { + const ticketIds = tickets.map((t) => t.getId()); + try { + const bridges = await TicketSuggestion.allByTicketIds(ticketIds); + for (const bridge of bridges) { + const tid = bridge.getTicketId(); + if (!bridgeMap.has(tid)) { + bridgeMap.set(tid, []); + } + bridgeMap.get(tid).push(bridge.getSuggestionId()); + } + } catch (err) { + log.warn({ err }, 'Failed to bulk-load bridge rows; response will omit suggestion links'); + } + } + const ticketsWithSuggestions = tickets.map( + (t) => TicketDto.toJSON(t, bridgeMap.get(t.getId()) || []), + ); + + return ok(ticketsWithSuggestions); + } + + /** + * Returns the single ticket linked to a suggestion via the TicketSuggestion bridge. + * + * GET /organizations/:organizationId/suggestions/:suggestionId/ticket + * + * Returns 404 when no ticket has been created for this suggestion. + */ + async function getTicketBySuggestion(requestContext) { + const { params } = requestContext; + const { organizationId, suggestionId } = params; + + if (!isValidUUID(organizationId)) { + return badRequest('organizationId must be a valid UUID'); + } + + if (!hasText(suggestionId)) { + return badRequest('suggestionId is required'); + } + + const { denied } = await loadOrgWithAccess(organizationId); + if (denied) { + return denied; + } + + let bridge; + try { + bridge = await TicketSuggestion.findBySuggestionId(suggestionId); + } catch (err) { + log.error({ organizationId, suggestionId, err }, 'Failed to look up TicketSuggestion'); + return internalServerError('Failed to look up ticket'); + } + + if (!bridge) { + return notFound(`No ticket found for suggestion ${suggestionId}`); + } + + let ticket; + try { + ticket = await Ticket.findById(bridge.getTicketId()); + } catch (err) { + log.error({ organizationId, ticketId: bridge.getTicketId(), err }, 'Failed to load ticket'); + return internalServerError('Failed to load ticket'); + } + + if (!ticket || ticket.getOrganizationId() !== organizationId) { + return notFound(`No ticket found for suggestion ${suggestionId}`); + } + + return ok({ + ...TicketDto.toJSON(ticket), + suggestionId, + opportunityId: bridge.getOpportunityId(), + createdAt: ticket.getCreatedAt?.(), + }); + } + + /** + * Lists all tickets for all suggestions under an opportunity. + * + * GET /organizations/:organizationId/opportunities/:opportunityId/tickets + * + * Response shape: array of ticket objects with `suggestions` bridge array. + */ + async function listTicketsByOpportunity(requestContext) { + const { params } = requestContext; + const { organizationId, opportunityId } = params; + + if (!isValidUUID(organizationId)) { + return badRequest('organizationId must be a valid UUID'); + } + + if (!hasText(opportunityId)) { + return badRequest('opportunityId is required'); + } + + const { denied } = await loadOrgWithAccess(organizationId); + if (denied) { + return denied; + } + + let tickets; + try { + tickets = (await Ticket.allByOpportunityId(opportunityId)) + .filter((t) => t.getOrganizationId() === organizationId); + } catch (err) { + log.error({ organizationId, opportunityId, err }, 'Failed to list tickets for opportunity'); + return internalServerError('Failed to list tickets'); + } + + if (tickets.length === 0) { + return ok([]); + } + + // Bulk-load bridge rows for all matching tickets, then group by ticket ID. + const bridgeMap = new Map(); + try { + const ticketIds = tickets.map((t) => t.getId()); + const bridges = await TicketSuggestion.allByTicketIds(ticketIds); + for (const bridge of bridges) { + const tid = bridge.getTicketId(); + if (!bridgeMap.has(tid)) { + bridgeMap.set(tid, []); + } + bridgeMap.get(tid).push(bridge.getSuggestionId()); + } + } catch (err) { + log.warn({ opportunityId, err }, 'Failed to bulk-load bridge rows; response will omit suggestion links'); + } + + return ok(tickets.map((t) => TicketDto.toJSON(t, bridgeMap.get(t.getId()) || []))); + } + + // ─── Ticket creation ────────────────────────────────────────────────────── + + /** + * Creates a Jira ticket for an opportunity and persists the result. + * + * POST /organizations/:organizationId/task-management/:provider/tickets + * + * Expected request body: + * ```json + * { + * "projectKey": "string (required)", + * "summary": "string (required)", + * "description": "string (optional, plain text)", + * "issueType": "string (optional, defaults to 'Task')", + * "labels": ["string"] (optional), + * "mode": "'individual' (default) | 'grouped'", + * "suggestionIds": ["uuid"] — max 10; v1 processes only the first element, + * "opportunityId": "uuid (optional)" + * } + * ``` + * + * Requires an `Idempotency-Key` request header (spec §Idempotent Ticket Creation). + * Deduplication is enforced via the `idempotency_keys` table with a 2-minute in-flight + * lock window; bridge-row presence is the permanent deduplication guard after expiry. + */ + async function createTicket(requestContext) { + const { params, data, attributes } = requestContext; + + const callerProfile = attributes?.authInfo?.getProfile?.(); + const createdBy = callerProfile?.sub ?? 'unknown'; + const { organizationId, provider } = params; + + // --- Input validation --------------------------------------------------- + + if (!isValidUUID(organizationId)) { + return badRequest('organizationId must be a valid UUID'); + } + + if (!hasText(provider)) { + return badRequest('provider is required'); + } + + if (!SUPPORTED_PROVIDERS.has(provider)) { + return badRequest(`Unsupported provider. Supported: ${[...SUPPORTED_PROVIDERS].join(', ')}`); + } + + const { denied } = await loadOrgWithAccess(organizationId); + if (denied) { + return denied; + } + + if (!isNonEmptyObject(data) || !hasText(data.summary)) { + return badRequest('Request body with summary is required'); + } + + if (!hasText(data.projectKey)) { + return badRequest('projectKey is required'); + } + + // suggestionIds — accept both array (spec) and singular form (compat). + const suggestionIdsRaw = data.suggestionIds ?? (data.suggestionId ? [data.suggestionId] : []); + const suggestionIds = Array.isArray(suggestionIdsRaw) ? suggestionIdsRaw : []; + + const primarySuggestionId = suggestionIds[0]; + + // mode — 'individual' (default, one ticket per suggestion) or 'grouped' (all suggestions + // into one ticket). grouped requires at least one suggestionId. + const mode = data.mode ?? TICKET_MODE_INDIVIDUAL; + if (mode !== TICKET_MODE_INDIVIDUAL && mode !== TICKET_MODE_GROUPED) { + return badRequest(`Invalid mode '${mode}'. Supported values: 'individual', 'grouped'.`); + } + if (mode === TICKET_MODE_GROUPED && suggestionIds.length === 0) { + return badRequest("Mode 'grouped' requires at least one suggestionId"); + } + + // Cap per mode: individual ≤15 (N tickets), grouped ≤1500 (1 ticket). + const suggestionIdsMax = mode === TICKET_MODE_GROUPED + ? SUGGESTION_IDS_MAX_GROUPED + : SUGGESTION_IDS_MAX_INDIVIDUAL; + if (suggestionIds.length > suggestionIdsMax) { + return badRequest(`suggestionIds must contain at most ${suggestionIdsMax} items for mode '${mode}'`); + } + + // --- Optional attachments validation (spec §Attachment Validation) --------- + // attachments: [{ content: base64 string, mimeType: string, filename: string }] + // Max 1 attachment per request (Lambda 6 MB sync payload limit). + // Decoded content is held in memory; all MIME/size/magic-byte checks happen + // inside ticketClient.uploadAttachment — we only pre-validate shape + size here + // so the caller gets a clear 400 before any downstream work is done. + + const attachments = Array.isArray(data.attachments) ? data.attachments : []; + if (attachments.length > 1) { + return badRequest('Attachments may contain at most 1 item per request'); + } + + let attachmentBuffer; + let attachmentMeta; + if (attachments.length === 1) { + const att = attachments[0]; + if (!hasText(att.content) || !hasText(att.mimeType) || !hasText(att.filename)) { + return badRequest('Each attachment must have content (base64), mimeType, and filename'); + } + if (!ALLOWED_ATTACHMENT_MIME_TYPES.has(att.mimeType)) { + return badRequest(`Unsupported attachment mimeType '${att.mimeType}'. Allowed: ${[...ALLOWED_ATTACHMENT_MIME_TYPES].join(', ')}`); + } + // Strip path separators and control characters (mirrors jira-cloud-client sanitizeFilename). + const sanitizedFilename = String(att.filename) + .replace(/[/\\]/g, '') + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001f\u007f]/g, '') + .trim() + .slice(0, 255) || 'attachment'; + const decoded = Buffer.from(att.content, 'base64'); + if (decoded.length === 0) { + return badRequest('Attachment content must not be empty'); + } + if (decoded.length > ATTACHMENT_MAX_BYTES) { + return badRequest(`attachment exceeds maximum size of ${ATTACHMENT_MAX_BYTES / (1024 * 1024)} MB`); + } + attachmentBuffer = decoded; + attachmentMeta = { mimeType: att.mimeType, filename: sanitizedFilename }; + } + + // Attachment in individual batch mode (N>1 suggestions) is not supported — each ticket + // would need its own attachment. Upload per-ticket via the attachment endpoint instead. + if (attachmentBuffer && mode === TICKET_MODE_INDIVIDUAL && suggestionIds.length > 1) { + return badRequest('Attachments are not supported when creating multiple tickets (individual batch mode). Upload attachments per-ticket via the attachment endpoint.'); + } + + // --- Resolve the active connection ---------------------------------------- + + const { connectionId } = data; + + if (!connectionId) { + return badRequest('connectionId is required'); + } + + if (!isValidUUIDAnyVersion(connectionId)) { + return badRequest('connectionId must be a valid UUID'); + } + + let connection; + try { + const conn = await loadConnectionForOrg(organizationId, connectionId); + if (!conn) { + return notFound(`Connection ${connectionId} not found for organization ${organizationId}`); + } + if (conn.getStatus() === 'requires_reauth') { + return createResponse({ message: 'connection_reauth_required' }, STATUS_CONFLICT); + } + if (conn.getStatus() !== 'active') { + return notFound(`Active ${provider} connection ${connectionId} not found for organization ${organizationId}`); + } + connection = conn; + } catch (err) { + log.error({ organizationId, provider, err }, 'Failed to load task-management connection'); + return internalServerError('Failed to load task-management connection'); + } + + // --- Idempotency-Key enforcement (spec §Idempotent Ticket Creation) -------- + // Derived server-side from the request payload so duplicate requests for the + // same suggestions are deduplicated regardless of which client sends them. + // Require at least one discriminator so the key is not a constant per org. + if (!hasText(data.opportunityId) && suggestionIds.length === 0) { + return badRequest('At least one of opportunityId or suggestionIds must be provided'); + } + + const idempotencyKey = createHash('sha256') + .update(`${data.opportunityId ?? ''}:${[...suggestionIds].sort().join(',')}`) + .digest('hex'); + + let existingEntry; + try { + existingEntry = await IdempotencyKey.findActiveKey(idempotencyKey, organizationId); + } catch (err) { + log.error({ organizationId, err }, 'Failed to look up idempotency key'); + return internalServerError('Service unavailable'); + } + + if (existingEntry) { + const status = existingEntry.getStatus(); + if (status === 'completed' || status === 'failed') { + const cached = existingEntry.getResponse(); + return createResponse(cached.body, cached.statusCode); + } + // status === 'processing' + log.warn({ organizationId, lockId: existingEntry.getId(), createdAt: existingEntry.getCreatedAt() }, 'Returning 409 — idempotency lock still processing'); + return createResponse({ message: 'Request already in flight', retryAfter: 2 }, STATUS_CONFLICT); + } + + // --- Validate suggestion(s) exist (spec §7 step 2) ------------------------- + // grouped: validate ALL suggestions upfront — fail fast if any is missing. + // individual: validate only primarySuggestionId pre-flight; batch loop validates + // remaining suggestions as it goes (best-effort per item). + + if (mode === TICKET_MODE_GROUPED) { + try { + const keys = suggestionIds.map((id) => ({ suggestionId: id })); + const { data: found } = await Suggestion.batchGetByKeys(keys); + const foundIds = new Set(found.map((s) => s.getId())); + const missing = suggestionIds.find((id) => !foundIds.has(id)); + if (missing) { + return notFound(`Suggestion ${missing} not found`); + } + } catch (err) { + log.error({ err }, 'Failed to validate suggestions'); + return internalServerError('Failed to validate suggestion'); + } + } else if (primarySuggestionId) { + let suggestion; + try { + suggestion = await Suggestion.findById(primarySuggestionId); + } catch (err) { + log.error({ primarySuggestionId, err }, 'Failed to look up suggestion'); + return internalServerError('Failed to validate suggestion'); + } + if (!suggestion) { + return notFound(`Suggestion ${primarySuggestionId} not found`); + } + } + + // --- Pre-flight: verify none of the suggestions already have a ticket ------- + + if (suggestionIds.length > 0) { + let alreadyTicketed; + try { + const bridges = await TicketSuggestion.allBySuggestionIds(suggestionIds); + alreadyTicketed = bridges.map((b) => b.getSuggestionId()); + } catch (err) { + log.error({ err }, 'Failed to check existing ticket bridges'); + return internalServerError('Failed to validate suggestion ticket status'); + } + if (alreadyTicketed.length > 0) { + return createResponse( + { + message: `Suggestion${alreadyTicketed.length > 1 ? 's' : ''} already ticketed: ${alreadyTicketed.join(', ')}`, + }, + STATUS_CONFLICT, + ); + } + } + + // --- Insert idempotency processing record --------------------------------- + // Connection and suggestion are validated — now commit to processing this request. + + const expiresAt = new Date(Date.now() + 2 * 60 * 1000).toISOString(); + let idempotencyKeyEntry; + try { + idempotencyKeyEntry = await IdempotencyKey.create({ + key: idempotencyKey, + organizationId, + endpoint: `POST /task-management/${provider}/tickets`, + status: 'processing', + expiresAt, + }); + } catch (err) { + const isUniqueViolation = err.message?.includes('unique') + || err.message?.includes('duplicate') + || err.code === '23505'; + if (isUniqueViolation) { + return createResponse({ message: 'Request already in flight' }, STATUS_CONFLICT); + } + log.error({ organizationId, err }, 'Failed to insert idempotency key'); + return internalServerError('Service unavailable'); + } + + async function markIdempotencyDone(responseBody, statusCode) { + try { + await idempotencyKeyEntry + .setStatus('completed') + .setResponse({ body: responseBody, statusCode }) + .save(); + } catch (err) { + log.warn({ err }, 'Failed to cache completed response in idempotency lock'); + } + } + + async function markIdempotencyFailed() { + try { + await idempotencyKeyEntry.remove(); + } catch (err) { + log.warn({ err }, 'Failed to delete idempotency key after failure'); + } + } + + // --- Create the ticket via the provider client ---------------------------- + + const ticketClient = buildTicketClient(connection); + + // ─── Individual batch path: N suggestionIds → N Jira tickets ───────────── + if (mode === TICKET_MODE_INDIVIDUAL && suggestionIds.length > 1) { + const results = []; + + for (const suggId of suggestionIds) { + // Validate suggestion (best-effort per item; first was already validated pre-flight) + let batchSuggestionOk = true; + if (suggId !== primarySuggestionId) { + try { + // eslint-disable-next-line no-await-in-loop + const sugg = await Suggestion.findById(suggId); + if (!sugg) { + results.push({ suggestionId: suggId, status: STATUS_NOT_FOUND, error: `Suggestion ${suggId} not found` }); + batchSuggestionOk = false; + } + } catch (err) { + log.error({ suggId, err }, 'Failed to look up suggestion in batch'); + results.push({ suggestionId: suggId, status: STATUS_INTERNAL_SERVER_ERROR, error: 'Failed to validate suggestion' }); + batchSuggestionOk = false; + } + } + + if (batchSuggestionOk) { + // Create Jira ticket for this suggestion + let batchTicketResult; + let batchTicketErr; + try { + // eslint-disable-next-line no-await-in-loop + batchTicketResult = await ticketClient.createTicket({ + projectKey: data.projectKey, + summary: data.summary, + description: data.description ?? '', + labels: data.labels ?? [], + issueType: data.issueType ?? 'Task', + priority: data.priority, + dueDate: data.dueDate, + components: data.components, + parent: data.parent, + }); + } catch (err) { + batchTicketErr = err; + } + + if (batchTicketErr) { + const { isGrantRevoked, isTokenExpired } = classifyProviderError(batchTicketErr); + + if (isGrantRevoked) { + // eslint-disable-next-line no-await-in-loop + await connection.markRequiresReauth() + .catch((reauthErr) => log.warn({ err: reauthErr }, 'Failed to mark connection as requires-reauth')); + results.push({ suggestionId: suggId, status: STATUS_CONFLICT, error: 'connection_reauth_required' }); + // Short-circuit: token is invalid, remaining suggestions will also fail. + // Mark them all as connection_reauth_required without calling Jira. + const remainingIds = suggestionIds.slice(suggestionIds.indexOf(suggId) + 1); + for (const remainingId of remainingIds) { + results.push({ suggestionId: remainingId, status: STATUS_CONFLICT, error: 'connection_reauth_required' }); + } + break; + } else if (isTokenExpired) { + results.push({ suggestionId: suggId, status: STATUS_CONFLICT, error: 'token_refresh_required' }); + const remainingIds = suggestionIds.slice(suggestionIds.indexOf(suggId) + 1); + for (const remainingId of remainingIds) { + results.push({ suggestionId: remainingId, status: STATUS_CONFLICT, error: 'token_refresh_required' }); + } + break; + } else { + log.error({ suggId, err: batchTicketErr }, 'Failed to create ticket in batch'); + results.push({ suggestionId: suggId, status: STATUS_INTERNAL_SERVER_ERROR, error: 'Failed to create ticket' }); + } + } else { + // Persist Ticket entity + let batchTicket; + let persistErr; + try { + // eslint-disable-next-line no-await-in-loop + batchTicket = await Ticket.create({ + organizationId, + taskManagementConnectionId: connection.getId(), + ticketProvider: provider, + createdBy, + opportunityId: data.opportunityId, + externalTicketId: batchTicketResult.ticketId, + ticketKey: batchTicketResult.ticketKey, + ticketUrl: batchTicketResult.ticketUrl, + ticketStatus: batchTicketResult.ticketStatus, + }); + } catch (err) { + persistErr = err; + } + + if (persistErr) { + log.error({ suggId, ticketKey: batchTicketResult.ticketKey, err: persistErr }, 'Ticket created in Jira but persistence failed in batch'); + results.push({ suggestionId: suggId, status: STATUS_INTERNAL_SERVER_ERROR, error: 'Ticket created but could not be saved' }); + } else { + log.info('Ticket created successfully (batch)', { + eventType: 'ticket.created', + orgId: organizationId, + connectionId: connection.getId(), + provider, + ticketKey: batchTicketResult.ticketKey, + suggestionId: suggId, + opportunityId: data.opportunityId, + imsActor: createdBy, + projectKey: data.projectKey, + issueType: data.issueType ?? 'Task', + }); + + // Create TicketSuggestion bridge + try { + // eslint-disable-next-line no-await-in-loop + await TicketSuggestion.create({ + ticketId: batchTicket.getId(), + suggestionId: suggId, + opportunityId: data.opportunityId, + createdBy, + }); + results.push({ + suggestionId: suggId, + status: STATUS_CREATED, + ticket: TicketDto.toJSON(batchTicket), + }); + } catch (err) { + const isDuplicate = err?.message?.includes('unique') || err?.code === '23505'; + if (isDuplicate) { + results.push({ suggestionId: suggId, status: STATUS_CONFLICT, error: `Suggestion ${suggId} has already been ticketed` }); + } else { + log.error({ ticketId: batchTicket.getId(), suggId, err }, 'Failed to create TicketSuggestion bridge record in batch'); + results.push({ suggestionId: suggId, status: STATUS_INTERNAL_SERVER_ERROR, error: 'Ticket created but suggestion link could not be saved' }); + } + } + } + } + } + } + + const hasSuccess = results.some((r) => r.status === STATUS_CREATED); + const allSuccess = results.every((r) => r.status === STATUS_CREATED); + if (hasSuccess) { + connection.setLastUsedAt(new Date().toISOString()); + connection.setErrorMessage(null); + } + connection.save().catch((saveErr) => { + log.warn({ saveErr }, 'Failed to update connection metadata after batch'); + }); + + const batchResponseBody = { mode, results }; + if (allSuccess) { + // Only cache when all items succeeded — partial failures must remain retryable. + await markIdempotencyDone(batchResponseBody, STATUS_MULTI_STATUS); + } else { + await markIdempotencyFailed(); + } + return createResponse(batchResponseBody, STATUS_MULTI_STATUS); + } + + // ─── Grouped path: M suggestionIds → 1 Jira ticket ─────────────────────── + if (mode === TICKET_MODE_GROUPED) { + let groupedTicketResult; + try { + groupedTicketResult = await ticketClient.createTicket({ + projectKey: data.projectKey, + summary: data.summary, + description: data.description ?? '', + labels: data.labels ?? [], + issueType: data.issueType ?? 'Task', + priority: data.priority, + dueDate: data.dueDate, + components: data.components, + parent: data.parent, + }); + } catch (err) { + const { isGrantRevoked, isTokenExpired } = classifyProviderError(err); + + if (isGrantRevoked) { + await connection.markRequiresReauth() + .catch((reauthErr) => log.warn({ err: reauthErr }, 'Failed to mark connection as requires-reauth')); + const body = { message: 'Jira OAuth token is invalid. Please reconnect the Jira integration.' }; + await markIdempotencyFailed(); + return createResponse(body, STATUS_CONFLICT); + } + if (isTokenExpired) { + const body = { message: 'Jira OAuth token expired. Please retry after refreshing tokens.' }; + await markIdempotencyFailed(); + return createResponse(body, STATUS_CONFLICT); + } + connection.setErrorMessage(err.message ?? 'Unknown grouped ticket creation error'); + connection.save().catch((saveErr) => { + log.warn({ saveErr }, 'Failed to persist errorMessage on connection'); + }); + + log.error({ organizationId, provider, err }, 'Failed to create grouped ticket'); + const body = { message: 'Failed to create ticket' }; + await markIdempotencyFailed(); + return internalServerError(body.message ?? 'Internal error'); + } + + let groupedTicket; + try { + groupedTicket = await Ticket.create({ + organizationId, + taskManagementConnectionId: connection.getId(), + ticketProvider: provider, + createdBy, + opportunityId: data.opportunityId, + externalTicketId: groupedTicketResult.ticketId, + ticketKey: groupedTicketResult.ticketKey, + ticketUrl: groupedTicketResult.ticketUrl, + ticketStatus: groupedTicketResult.ticketStatus, + }); + } catch (err) { + log.error( + { + organizationId, provider, ticketKey: groupedTicketResult.ticketKey, err, + }, + 'Grouped ticket created in Jira but persistence failed', + ); + const body = { message: 'Ticket created but could not be saved' }; + await markIdempotencyFailed(); + return internalServerError(body.message ?? 'Internal error'); + } + + log.info('Grouped ticket created successfully', { + eventType: 'ticket.created', + orgId: organizationId, + connectionId: connection.getId(), + provider, + ticketKey: groupedTicketResult.ticketKey, + suggestionIds, + opportunityId: data.opportunityId, + imsActor: createdBy, + projectKey: data.projectKey, + issueType: data.issueType ?? 'Task', + }); + + connection.setLastUsedAt(new Date().toISOString()); + connection.setErrorMessage(null); + connection.save().catch((saveErr) => { + log.warn({ saveErr }, 'Failed to update lastUsedAt on connection'); + }); + + // Link all suggestions to the single ticket — chunked at 50 concurrent, non-fatal per item. + const BRIDGE_CREATE_CONCURRENCY = 50; + const linkWarnings = []; + for (let i = 0; i < suggestionIds.length; i += BRIDGE_CREATE_CONCURRENCY) { + const chunk = suggestionIds.slice(i, i + BRIDGE_CREATE_CONCURRENCY); + // eslint-disable-next-line no-await-in-loop + const chunkWarnings = await Promise.all( + chunk.map(async (suggId) => { + try { + await TicketSuggestion.create({ + ticketId: groupedTicket.getId(), + suggestionId: suggId, + opportunityId: data.opportunityId, + createdBy, + }); + return null; + } catch (err) { + const isDuplicate = err?.message?.includes('unique') || err?.code === '23505'; + if (isDuplicate) { + return `Suggestion ${suggId} has already been linked to another ticket`; + } + log.error({ ticketId: groupedTicket.getId(), suggId, err }, 'Failed to create TicketSuggestion bridge record in grouped mode'); + return `Failed to link suggestion ${suggId} to ticket`; + } + }), + ); + linkWarnings.push(...chunkWarnings.filter(Boolean)); + } + + // Upload attachment if provided — one attachment on the single grouped ticket. + let groupedAttachmentWarning; + if (attachmentBuffer) { + try { + await ticketClient.uploadAttachment(groupedTicketResult.ticketKey, { + content: attachmentBuffer, + ...attachmentMeta, + }); + } catch (err) { + log.warn({ ticketKey: groupedTicketResult.ticketKey, err }, 'Grouped ticket created but attachment upload failed'); + groupedAttachmentWarning = 'Ticket created but attachment upload failed. Retry via the attachment endpoint.'; + } + } + + const groupedResponseBody = { + mode, + ...TicketDto.toJSON(groupedTicket), + suggestionIds, + ...(linkWarnings.length > 0 ? { linkWarnings } : {}), + ...(groupedAttachmentWarning ? { attachmentWarning: groupedAttachmentWarning } : {}), + }; + await markIdempotencyDone(groupedResponseBody, STATUS_CREATED); + return created(groupedResponseBody); + } + + // ─── Single ticket path (individual, ≤1 suggestion) ────────────────────── + + let ticketResult; + try { + ticketResult = await ticketClient.createTicket({ + projectKey: data.projectKey, + summary: data.summary, + description: data.description ?? '', + labels: data.labels ?? [], + issueType: data.issueType ?? 'Task', + priority: data.priority, + dueDate: data.dueDate, + components: data.components, + parent: data.parent, + }); + } catch (err) { + const { isGrantRevoked, isTokenExpired } = classifyProviderError(err); + + if (isGrantRevoked) { + await connection.markRequiresReauth() + .catch((reauthErr) => log.warn({ err: reauthErr }, 'Failed to mark connection as requires-reauth')); + const body = { message: 'Jira OAuth token is invalid. Please reconnect the Jira integration.' }; + await markIdempotencyFailed(); + return createResponse(body, STATUS_CONFLICT); + } + if (isTokenExpired) { + const body = { message: 'Jira OAuth token expired. Please retry after refreshing tokens.' }; + await markIdempotencyFailed(); + return createResponse(body, STATUS_CONFLICT); + } + + connection.setErrorMessage(err.message ?? 'Unknown ticket creation error'); + connection.save().catch((saveErr) => { + log.warn({ saveErr }, 'Failed to persist errorMessage on connection'); + }); + + log.error({ organizationId, provider, err }, 'Failed to create ticket'); + const body = { message: 'Failed to create ticket' }; + await markIdempotencyFailed(); + return internalServerError(body.message ?? 'Internal error'); + } + + // --- Persist the ticket record -------------------------------------------- + + let ticket; + try { + ticket = await Ticket.create({ + organizationId, + taskManagementConnectionId: connection.getId(), + ticketProvider: provider, + createdBy, + opportunityId: data.opportunityId, + externalTicketId: ticketResult.ticketId, + ticketKey: ticketResult.ticketKey, + ticketUrl: ticketResult.ticketUrl, + ticketStatus: ticketResult.ticketStatus, + }); + } catch (err) { + log.error( + { + organizationId, + provider, + externalTicketId: ticketResult.ticketId, + ticketKey: ticketResult.ticketKey, + ticketUrl: ticketResult.ticketUrl, + err, + }, + 'Ticket created in Jira but persistence failed', + ); + const body = { + message: 'Ticket created but could not be saved', + ticketKey: ticketResult.ticketKey, + ticketUrl: ticketResult.ticketUrl, + }; + await markIdempotencyFailed(); + return internalServerError(body.message ?? 'Internal error'); + } + + // Emit structured audit event per spec §Logging & Audit Events. + log.info('Ticket created successfully', { + eventType: 'ticket.created', + orgId: organizationId, + connectionId: connection.getId(), + provider, + ticketKey: ticketResult.ticketKey, + suggestionIds: suggestionIds.length > 0 ? suggestionIds : undefined, + opportunityId: data.opportunityId, + imsActor: createdBy, + projectKey: data.projectKey, + issueType: data.issueType ?? 'Task', + }); + + connection.setLastUsedAt(new Date().toISOString()); + connection.setErrorMessage(null); + connection.save().catch((saveErr) => { + log.warn({ saveErr }, 'Failed to update lastUsedAt on connection'); + }); + + // --- Create the TicketSuggestion bridge row -------------------------------- + + if (primarySuggestionId) { + try { + await TicketSuggestion.create({ + ticketId: ticket.getId(), + suggestionId: primarySuggestionId, + opportunityId: data.opportunityId, + createdBy, + }); + } catch (err) { + const isDuplicate = err?.message?.includes('unique') || err?.code === '23505'; + if (isDuplicate) { + const body = { message: `Suggestion ${primarySuggestionId} has already been ticketed` }; + await markIdempotencyFailed(); + return createResponse(body, STATUS_CONFLICT); + } + log.error( + { ticketId: ticket.getId(), suggestionId: primarySuggestionId, err }, + 'Failed to create TicketSuggestion bridge record', + ); + const body = { message: 'Ticket created but suggestion link could not be saved' }; + await markIdempotencyFailed(); + return internalServerError(body.message ?? 'Internal error'); + } + } + + // --- Upload attachment (spec §30) — partial success: ticket already exists - + + let attachmentWarning; + if (attachmentBuffer) { + try { + await ticketClient.uploadAttachment(ticketResult.ticketKey, { + content: attachmentBuffer, + ...attachmentMeta, + }); + } catch (err) { + // Spec §Attachment failure handling: "partial success acceptable; retry via + // attachment endpoint". Ticket is already created — do not roll back. + log.warn( + { ticketKey: ticketResult.ticketKey, err }, + 'Ticket created but attachment upload failed', + ); + attachmentWarning = 'Ticket created but attachment upload failed. Retry via the attachment endpoint.'; + } + } + + const responseBody = { + mode, + ...TicketDto.toJSON(ticket), + suggestionId: primarySuggestionId ?? undefined, + ...(attachmentWarning ? { attachmentWarning } : {}), + }; + await markIdempotencyDone(responseBody, STATUS_CREATED); + return created(responseBody); + } + + // ─── Project listing ────────────────────────────────────────────────────── + + /** + * Lists available Jira projects for a specific connection. + * Used by the UI project picker when creating a ticket. + * + * GET /organizations/:organizationId/task-management/connections/:connectionId/projects + */ + async function listProjects(requestContext) { + const { params } = requestContext; + const { organizationId, connectionId } = params; + + if (!isValidUUID(organizationId)) { + return badRequest('organizationId must be a valid UUID'); + } + + if (!isValidUUIDAnyVersion(connectionId)) { + return badRequest('connectionId must be a valid UUID'); + } + + const { denied } = await loadOrgWithAccess(organizationId); + if (denied) { + return denied; + } + + let connection; + try { + const conn = await loadConnectionForOrg(organizationId, connectionId); + if (!conn) { + return notFound(`Active connection ${connectionId} not found for organization ${organizationId}`); + } + if (conn.getStatus() === 'requires_reauth') { + return createResponse({ message: 'connection_reauth_required' }, STATUS_CONFLICT); + } + if (conn.getStatus() !== 'active') { + return notFound(`Active connection ${connectionId} not found for organization ${organizationId}`); + } + connection = conn; + } catch (err) { + log.error({ organizationId, connectionId, err }, 'Failed to load connection for listProjects'); + return internalServerError('Failed to load task-management connection'); + } + + let projects; + try { + const ticketClient = buildTicketClient(connection); + projects = await ticketClient.listProjects(); + } catch (err) { + const { isGrantRevoked, isTokenExpired } = classifyProviderError(err); + + if (isGrantRevoked) { + await connection.markRequiresReauth() + .catch((reauthErr) => log.warn({ err: reauthErr }, 'Failed to mark connection as requires-reauth')); + return createResponse( + { message: 'Jira OAuth token is invalid. Please reconnect the Jira integration.' }, + STATUS_CONFLICT, + ); + } + if (isTokenExpired) { + return createResponse( + { message: 'Jira OAuth token expired. Please retry after refreshing tokens.' }, + STATUS_CONFLICT, + ); + } + + log.error({ organizationId, connectionId, err }, 'Failed to list projects'); + return internalServerError('Failed to list projects'); + } + + return ok({ projects }); + } + + /** + * GET /organizations/:organizationId/task-management/connections/:connectionId/issue-types + * + * Returns all non-subtask issue types for a given project. + * projectId (numeric Jira project ID, returned by listProjects) is required + * as a query parameter. The hierarchy endpoint used internally requires the + * numeric ID, not the project key. + */ + async function listIssueTypes(requestContext) { + const { params } = requestContext; + const qs = Object.fromEntries(new URL(requestContext.request.url).searchParams); + const { organizationId, connectionId } = params; + const projectId = qs.projectId ?? null; + + if (!isValidUUID(organizationId)) { + return badRequest('organizationId must be a valid UUID'); + } + + if (!isValidUUIDAnyVersion(connectionId)) { + return badRequest('connectionId must be a valid UUID'); + } + + if (!hasText(projectId)) { + return badRequest('projectId query parameter is required'); + } + + if (!/^\d+$/.test(projectId)) { + return badRequest('projectId must be a numeric Jira project ID'); + } + + const { denied } = await loadOrgWithAccess(organizationId); + if (denied) { + return denied; + } + + let connection; + try { + const conn = await loadConnectionForOrg(organizationId, connectionId); + if (!conn) { + return notFound(`Active connection ${connectionId} not found for organization ${organizationId}`); + } + if (conn.getStatus() === 'requires_reauth') { + return createResponse({ message: 'connection_reauth_required' }, STATUS_CONFLICT); + } + if (conn.getStatus() !== 'active') { + return notFound(`Active connection ${connectionId} not found for organization ${organizationId}`); + } + connection = conn; + } catch (err) { + log.error({ organizationId, connectionId, err }, 'Failed to load connection for listIssueTypes'); + return internalServerError('Failed to load task-management connection'); + } + + let issueTypes; + try { + const ticketClient = buildTicketClient(connection); + issueTypes = await ticketClient.listIssueTypes(projectId); + } catch (err) { + const { isGrantRevoked, isTokenExpired } = classifyProviderError(err); + + if (isGrantRevoked) { + await connection.markRequiresReauth() + .catch((reauthErr) => log.warn({ err: reauthErr }, 'Failed to mark connection as requires-reauth')); + return createResponse( + { message: 'Jira OAuth token is invalid. Please reconnect the Jira integration.' }, + STATUS_CONFLICT, + ); + } + if (isTokenExpired) { + return createResponse( + { message: 'Jira OAuth token expired. Please retry after refreshing tokens.' }, + STATUS_CONFLICT, + ); + } + + log.error({ + organizationId, connectionId, projectId, err, + }, 'Failed to list issue types'); + return internalServerError('Failed to list issue types'); + } + + return ok({ issueTypes }); + } + + return { + listConnections, + getConnection, + listTickets, + getTicketBySuggestion, + listTicketsByOpportunity, + createTicket, + listProjects, + listIssueTypes, + }; +} + +export default TaskManagementController; diff --git a/src/dto/task-management-connection.js b/src/dto/task-management-connection.js new file mode 100644 index 0000000000..b61f44bdf0 --- /dev/null +++ b/src/dto/task-management-connection.js @@ -0,0 +1,28 @@ +/* + * 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. + */ + +export const TaskManagementConnectionDto = { + toJSON(conn) { + return { + id: conn.getId(), + organizationId: conn.getOrganizationId(), + provider: conn.getProvider(), + status: conn.getStatus(), + displayName: conn.getDisplayName?.(), + instanceUrl: conn.getInstanceUrl?.(), + connectedBy: conn.getConnectedBy?.(), + metadata: conn.getMetadata(), + createdAt: conn.getCreatedAt?.(), + updatedAt: conn.getUpdatedAt?.(), + }; + }, +}; diff --git a/src/dto/ticket.js b/src/dto/ticket.js new file mode 100644 index 0000000000..5a2f8a1b00 --- /dev/null +++ b/src/dto/ticket.js @@ -0,0 +1,44 @@ +/* + * 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. + */ + +export const TicketDto = { + /** + * Converts a Ticket entity to a plain response object. + * connectionId is included per the spec response shape. + * + * @param {object} ticket + * @param {Array<{suggestionId: string, opportunityId: string}>} [suggestions] + */ + toJSON(ticket, suggestions) { + const out = { + id: ticket.getId(), + organizationId: ticket.getOrganizationId(), + connectionId: ticket.getTaskManagementConnectionId?.() ?? ticket.getConnectionId?.(), + externalTicketId: ticket.getExternalTicketId(), + ticketKey: ticket.getTicketKey(), + ticketUrl: ticket.getTicketUrl(), + ticketStatus: ticket.getTicketStatus(), + ticketProvider: ticket.getTicketProvider(), + opportunityId: ticket.getOpportunityId?.() ?? null, + createdAt: ticket.getCreatedAt?.() ?? null, + statusSyncedAt: null, // v1: always null; populated by v2 webhook sync + }; + + // List endpoints include the suggestions bridge array per spec response shape. + // Creation endpoint uses the scalar suggestionId for backward compat. + if (suggestions !== undefined) { + out.suggestions = suggestions; + } + + return out; + }, +}; diff --git a/src/index.js b/src/index.js index 93d3a2fe66..d842d73f7c 100644 --- a/src/index.js +++ b/src/index.js @@ -45,6 +45,7 @@ import getRouteHandlers from './routes/index.js'; import matchPath, { sanitizePath } from './utils/route-utils.js'; import AuditsController from './controllers/audits.js'; +import TaskManagementController from './controllers/task-management.js'; import OrganizationsController from './controllers/organizations.js'; import ProjectsController from './controllers/project.js'; import SitesController from './controllers/sites.js'; @@ -295,6 +296,7 @@ async function run(request, context) { const serenityController = SerenityController(context, log, context.env); const elementsController = ElementsController(context, log, context.env); const proxyController = ProxyController(); + const taskManagementController = TaskManagementController(context); const routeHandlers = getRouteHandlers( auditsController, @@ -360,6 +362,7 @@ async function run(request, context) { serenityController, elementsController, proxyController, + taskManagementController, redirectsController, ); @@ -396,6 +399,9 @@ async function run(request, context) { if (params.preflightId && !isValidUUID(params.preflightId)) { return badRequest('Preflight Id is invalid. Please provide a valid UUID.'); } + if (params.connectionId && !isValidUUIDAnyVersion(params.connectionId)) { + return badRequest('Connection Id is invalid. Please provide a valid UUID.'); + } context.params = params; context.request = request; diff --git a/src/routes/facs-capabilities.js b/src/routes/facs-capabilities.js index 3dbf26e00d..e16b56994b 100644 --- a/src/routes/facs-capabilities.js +++ b/src/routes/facs-capabilities.js @@ -222,6 +222,16 @@ const routeFacsCapabilities = { // Monitoring / admin telemetry 'GET /monitoring/drs-bp-pg-audit', // internal monitoring + // Task management (Jira OAuth 3LO) — org-scoped, JWT-authenticated; not a FACS surface in v1. + 'GET /organizations/:organizationId/task-management/connections', // JWT session + 'GET /organizations/:organizationId/task-management/connections/:connectionId', // JWT session + 'GET /organizations/:organizationId/task-management/tickets', // JWT session + 'GET /organizations/:organizationId/suggestions/:suggestionId/ticket', // JWT session + 'GET /organizations/:organizationId/opportunities/:opportunityId/tickets', // JWT session + 'POST /organizations/:organizationId/task-management/:provider/tickets', // JWT session + 'GET /organizations/:organizationId/task-management/connections/:connectionId/projects', // JWT session + 'GET /organizations/:organizationId/task-management/connections/:connectionId/issue-types', // JWT session + // Ephemeral-run admin surface 'POST /ephemeral-run/batch', // admin/internal 'GET /ephemeral-run/batch/:batchId/status', // admin/internal @@ -1178,6 +1188,9 @@ const routeFacsCapabilities = { // agentic-categories and agentic-page-types routes. It is a label, not // a standalone FACS resource. 'name', + // Task-management connection and provider — sub-resource ids, not + // independently ReBAC-controlled. + 'connectionId', 'provider', 'fixId', 'geoExperimentId', 'guidelineId', 'intentKey', 'jobId', 'jobType', 'onboardingId', 'opportunityId', 'plgOnboardingId', 'promptId', 'questionKey', 'reportId', 'suggestionId', 'tokenId', diff --git a/src/routes/index.js b/src/routes/index.js index 633b7f7cff..5a4ce97370 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -109,6 +109,7 @@ function isStaticRoute(routePattern) { * @param {Object} serenityController - Serenity API controller (prompts + markets). * @param {Object} elementsController - Elements API controller (Semrush Elements wrappers). * @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. * @return {{staticRoutes: {}, dynamicRoutes: {}}} - An object with static and dynamic routes. */ @@ -176,6 +177,7 @@ export default function getRouteHandlers( serenityController, elementsController, proxyController, + taskManagementController, redirectsController, ) { const staticRoutes = {}; @@ -266,6 +268,14 @@ export default function getRouteHandlers( 'GET /organizations/:organizationId/projects': organizationsController.getProjectsByOrganizationId, 'GET /organizations/:organizationId/projects/:projectId/sites': organizationsController.getSitesByProjectIdAndOrganizationId, 'GET /organizations/:organizationId/by-project-name/:projectName/sites': organizationsController.getSitesByProjectNameAndOrganizationId, + 'GET /organizations/:organizationId/task-management/connections': taskManagementController.listConnections, + 'GET /organizations/:organizationId/task-management/connections/:connectionId': taskManagementController.getConnection, + 'GET /organizations/:organizationId/task-management/tickets': taskManagementController.listTickets, + 'GET /organizations/:organizationId/suggestions/:suggestionId/ticket': taskManagementController.getTicketBySuggestion, + 'GET /organizations/:organizationId/opportunities/:opportunityId/tickets': taskManagementController.listTicketsByOpportunity, + 'POST /organizations/:organizationId/task-management/:provider/tickets': taskManagementController.createTicket, + 'GET /organizations/:organizationId/task-management/connections/:connectionId/projects': taskManagementController.listProjects, + 'GET /organizations/:organizationId/task-management/connections/:connectionId/issue-types': taskManagementController.listIssueTypes, 'GET /projects': projectsController.getAll, 'POST /projects': projectsController.createProject, 'GET /projects/:projectId': projectsController.getByID, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index dd213e06dc..498a819ced 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -186,6 +186,17 @@ export const INTERNAL_ROUTES = [ // monitoring endpoint. 'GET /monitoring/drs-bp-pg-audit', + // Task management (Jira OAuth 3LO) - org-scoped, JWT-authenticated; not exposed to S2S + // consumers in v1. A dedicated capability (e.g. `taskManagement:write`) will be + // introduced in v2 when S2S ticket creation is required. + 'GET /organizations/:organizationId/task-management/connections', + 'GET /organizations/:organizationId/task-management/connections/:connectionId', + 'GET /organizations/:organizationId/task-management/tickets', + 'GET /organizations/:organizationId/suggestions/:suggestionId/ticket', + 'GET /organizations/:organizationId/opportunities/:opportunityId/tickets', + 'POST /organizations/:organizationId/task-management/:provider/tickets', + 'GET /organizations/:organizationId/task-management/connections/:connectionId/projects', + 'GET /organizations/:organizationId/task-management/connections/:connectionId/issue-types', // Hybrid permission model — state-layer management + capability // introspection. Customer-org admins manage their own ReBAC bindings here, // self-gated in the controller by `/can_manage_users` (CRUD) and diff --git a/test/controllers/task-management.test.js b/test/controllers/task-management.test.js new file mode 100644 index 0000000000..490c53d11b --- /dev/null +++ b/test/controllers/task-management.test.js @@ -0,0 +1,3498 @@ +/* + * Copyright 2025 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. + */ + +/* eslint-disable max-classes-per-file, max-len */ + +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; + +use(sinonChai); + +const ORG_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; +const CONN_ID = 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff'; +const TICKET_ID = 'cccccccc-dddd-eeee-ffff-aaaaaaaaaaaa'; +const SUGGESTION_ID = 'dddddddd-eeee-ffff-aaaa-bbbbbbbbbbbb'; +const OPPORTUNITY_ID = 'eeeeeeee-ffff-aaaa-bbbb-cccccccccccc'; +const PROVIDER = 'jira_cloud'; + +// Minimal fake connection +function makeConnection(overrides = {}) { + return { + getId: () => CONN_ID, + getOrganizationId: () => ORG_ID, + getProvider: () => PROVIDER, + getStatus: () => 'active', + getDisplayName: () => 'My Jira Site', + getInstanceUrl: () => 'https://mysiteurl.atlassian.net', + getConnectedBy: () => 'ims-user-1', + getMetadata: () => ({ cloudId: '11111111-2222-3333-4444-555555555555' }), + getCreatedAt: () => '2025-01-01T00:00:00Z', + getUpdatedAt: () => '2025-01-01T00:00:00Z', + markDisconnected: sinon.stub().resolves(), + markRequiresReauth: sinon.stub().resolves(), + setLastUsedAt: sinon.stub().returnsThis(), + setErrorMessage: sinon.stub().returnsThis(), + save: sinon.stub().resolves(), + ...overrides, + }; +} + +function makeTicket(overrides = {}) { + return { + getId: () => TICKET_ID, + getOrganizationId: () => ORG_ID, + getTaskManagementConnectionId: () => CONN_ID, + getConnectionId: () => undefined, + getExternalTicketId: () => 'PROJ-42', + getTicketKey: () => 'PROJ-42', + getTicketUrl: () => 'https://mysite.atlassian.net/browse/PROJ-42', + getTicketStatus: () => 'open', + getTicketProvider: () => PROVIDER, + getOpportunityId: () => OPPORTUNITY_ID, + getCreatedBy: () => 'ims-user-1', + getCreatedAt: () => '2025-01-01T00:00:00Z', + ...overrides, + }; +} + +function makeBridge(overrides = {}) { + return { + getTicketId: () => TICKET_ID, + getSuggestionId: () => SUGGESTION_ID, + getOpportunityId: () => OPPORTUNITY_ID, + ...overrides, + }; +} + +function makeSuggestion(overrides = {}) { + return { + getId: () => SUGGESTION_ID, + getOpportunityId: () => OPPORTUNITY_ID, + ...overrides, + }; +} + +function makeOrg(overrides = {}) { + return { + getId: () => ORG_ID, + getImsOrgId: () => 'test-ims-org-id', + ...overrides, + }; +} + +function makeDataAccess(overrides = {}) { + return { + TaskManagementConnection: { + allByOrganizationId: sinon.stub().resolves([]), + findById: sinon.stub().resolves(null), + findActiveByOrganizationAndProvider: sinon.stub().resolves(null), + ...overrides.TaskManagementConnection, + }, + Ticket: { + allByOrganizationId: sinon.stub().resolves([]), + allByOpportunityId: sinon.stub().resolves([]), + findById: sinon.stub().resolves(null), + findByOpportunityId: sinon.stub().resolves(null), + create: sinon.stub().resolves(makeTicket()), + ...overrides.Ticket, + }, + TicketSuggestion: { + allByTicketId: sinon.stub().resolves([]), + allByTicketIds: sinon.stub().resolves([]), + allBySuggestionIds: sinon.stub().resolves([]), + findBySuggestionId: sinon.stub().resolves(null), + create: sinon.stub().resolves(), + ...overrides.TicketSuggestion, + }, + Suggestion: { + findById: sinon.stub().resolves(null), + batchGetByKeys: sinon.stub().callsFake((keys) => Promise.resolve({ + data: keys.map((k) => makeSuggestion({ getId: () => k.suggestionId })), + unprocessed: [], + })), + ...overrides.Suggestion, + }, + Organization: { + findById: sinon.stub().resolves(makeOrg()), + ...overrides.Organization, + }, + IdempotencyKey: { + findActiveKey: sinon.stub().resolves(null), + create: sinon.stub().resolves({ + setStatus: sinon.stub().returnsThis(), + setResponse: sinon.stub().returnsThis(), + save: sinon.stub().resolves(), + remove: sinon.stub().resolves(), + }), + ...overrides.IdempotencyKey, + }, + }; +} + +function makeContext(overrides = {}) { + // Extract dataAccess separately so ...rest doesn't overwrite the fully-constructed dataAccess. + const { dataAccess: dataOverrides, ...rest } = overrides; + return { + dataAccess: makeDataAccess(dataOverrides ?? {}), + log: { + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + }, + // AccessControlUtil.fromContext() requires pathInfo.headers and attributes.authInfo. + // Default to an admin identity so hasAccess() returns true without any DB lookups. + pathInfo: { method: 'GET', suffix: '/', headers: {} }, + attributes: { authInfo: { isAdmin: () => true, getType: () => 'api_key' } }, + ...rest, + }; +} + +describe('TaskManagementController', () => { + let TaskManagementController; + + beforeEach(async () => { + TaskManagementController = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + GetSecretValueCommand: class {}, + PutSecretValueCommand: class {}, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().resolves({ + ticketId: 'PROJ-42', + ticketKey: 'PROJ-42', + ticketUrl: 'https://mysite.atlassian.net/browse/PROJ-42', + ticketStatus: 'To Do', + }), + uploadAttachment: sinon.stub().resolves(), + }), + }, + }, + })).default; + }); + + afterEach(() => { + sinon.restore(); + }); + + // ─── constructor ──────────────────────────────────────────────────────────── + + describe('constructor', () => { + it('throws when context missing', () => { + expect(() => TaskManagementController(null)).to.throw('Context required'); + }); + + it('throws when dataAccess missing', () => { + expect(() => TaskManagementController({ log: { info: sinon.stub() } })) + .to.throw('Data access required'); + }); + + it('throws when TaskManagementConnection missing', () => { + const ctx = makeContext(); + delete ctx.dataAccess.TaskManagementConnection; + expect(() => TaskManagementController(ctx)) + .to.throw('TaskManagementConnection collection not available'); + }); + + it('throws when Ticket missing', () => { + const ctx = makeContext(); + delete ctx.dataAccess.Ticket; + expect(() => TaskManagementController(ctx)) + .to.throw('Ticket collection not available'); + }); + + it('throws when TicketSuggestion missing', () => { + const ctx = makeContext(); + delete ctx.dataAccess.TicketSuggestion; + expect(() => TaskManagementController(ctx)) + .to.throw('TicketSuggestion collection not available'); + }); + + it('throws when Suggestion missing', () => { + const ctx = makeContext(); + delete ctx.dataAccess.Suggestion; + expect(() => TaskManagementController(ctx)) + .to.throw('Suggestion collection not available'); + }); + + it('throws when Organization missing', () => { + const ctx = makeContext(); + delete ctx.dataAccess.Organization; + expect(() => TaskManagementController(ctx)) + .to.throw('Organization collection not available'); + }); + + it('throws when IdempotencyKey missing', () => { + const ctx = makeContext(); + delete ctx.dataAccess.IdempotencyKey; + expect(() => TaskManagementController(ctx)) + .to.throw('IdempotencyKey collection not available'); + }); + + it('returns controller with all methods', () => { + const ctrl = TaskManagementController(makeContext()); + expect(ctrl).to.have.all.keys( + 'listConnections', + 'getConnection', + 'listTickets', + 'getTicketBySuggestion', + 'listTicketsByOpportunity', + 'createTicket', + 'listProjects', + 'listIssueTypes', + ); + }); + }); + + // ─── listConnections ──────────────────────────────────────────────────────── + + describe('listConnections', () => { + it('returns 400 for invalid organizationId', async () => { + const { listConnections } = TaskManagementController(makeContext()); + const res = await listConnections({ params: { organizationId: 'bad-uuid' }, request: { url: 'http://localhost/' } }); + expect(res.status).to.equal(400); + }); + + it('returns 404 when organization not found', async () => { + const ctx = makeContext({ dataAccess: { Organization: { findById: sinon.stub().resolves(null) } } }); + const { listConnections } = TaskManagementController(ctx); + const res = await listConnections({ params: { organizationId: ORG_ID }, request: { url: 'http://localhost/' } }); + expect(res.status).to.equal(404); + }); + + it('returns 403 when caller lacks access to organization', async () => { + const ForbiddenCtrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { TicketClientFactory: { create: sinon.stub() } }, + '../../src/support/access-control-util.js': { + default: { + fromContext: sinon.stub().returns({ hasAccess: sinon.stub().resolves(false) }), + }, + }, + })).default; + const { listConnections } = ForbiddenCtrl(makeContext()); + const res = await listConnections({ params: { organizationId: ORG_ID }, request: { url: 'http://localhost/' } }); + expect(res.status).to.equal(403); + }); + + it('returns 500 on collection error', async () => { + const ctx = makeContext(); + ctx.dataAccess.TaskManagementConnection.allByOrganizationId.rejects(new Error('db error')); + const { listConnections } = TaskManagementController(ctx); + const res = await listConnections({ params: { organizationId: ORG_ID }, request: { url: 'http://localhost/' } }); + expect(res.status).to.equal(500); + }); + + it('returns empty array when no connections', async () => { + const { listConnections } = TaskManagementController(makeContext()); + const res = await listConnections({ params: { organizationId: ORG_ID }, request: { url: 'http://localhost/' } }); + expect(res.status).to.equal(200); + const body = await res.json(); + expect(body).to.deep.equal([]); + }); + + it('returns serialized connections', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { TaskManagementConnection: { allByOrganizationId: sinon.stub().resolves([conn]) } }, + }); + const { listConnections } = TaskManagementController(ctx); + const res = await listConnections({ params: { organizationId: ORG_ID }, request: { url: 'http://localhost/' } }); + expect(res.status).to.equal(200); + const [first] = await res.json(); + expect(first.id).to.equal(CONN_ID); + expect(first.displayName).to.equal('My Jira Site'); + expect(first.instanceUrl).to.equal('https://mysiteurl.atlassian.net'); + expect(first.connectedBy).to.equal('ims-user-1'); + }); + + // ── Branch 1: provider filter NOT applied when qs.provider is falsy ────────── + it('returns all connections when provider query param is absent', async () => { + const conn1 = makeConnection(); + const conn2 = makeConnection({ getProvider: () => 'servicenow' }); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + allByOrganizationId: sinon.stub().resolves([conn1, conn2]), + }, + }, + }); + const { listConnections } = TaskManagementController(ctx); + const res = await listConnections({ + params: { organizationId: ORG_ID }, + request: { url: 'http://localhost/' }, // no provider key — falsy path + }); + expect(res.status).to.equal(200); + const body = await res.json(); + expect(body).to.have.lengthOf(2); + }); + + it('filters by provider query param', async () => { + const conn1 = makeConnection({ getProvider: () => 'jira_cloud' }); + const conn2 = makeConnection({ getId: () => 'other-id', getProvider: () => 'github' }); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { allByOrganizationId: sinon.stub().resolves([conn1, conn2]) }, + }, + }); + const { listConnections } = TaskManagementController(ctx); + const res = await listConnections({ params: { organizationId: ORG_ID }, request: { url: 'http://localhost/?provider=jira_cloud' } }); + const body = await res.json(); + expect(body).to.have.length(1); + expect(body[0].id).to.equal(CONN_ID); + }); + }); + + // ─── getConnection ────────────────────────────────────────────────────────── + + describe('getConnection', () => { + it('returns 400 for invalid organizationId', async () => { + const { getConnection } = TaskManagementController(makeContext()); + const res = await getConnection({ params: { organizationId: 'bad', connectionId: CONN_ID } }); + expect(res.status).to.equal(400); + }); + + it('returns 400 for invalid connectionId', async () => { + const { getConnection } = TaskManagementController(makeContext()); + const res = await getConnection({ params: { organizationId: ORG_ID, connectionId: 'bad' } }); + expect(res.status).to.equal(400); + }); + + it('returns 404 when organization not found', async () => { + const ctx = makeContext({ dataAccess: { Organization: { findById: sinon.stub().resolves(null) } } }); + const { getConnection } = TaskManagementController(ctx); + const res = await getConnection({ params: { organizationId: ORG_ID, connectionId: CONN_ID } }); + expect(res.status).to.equal(404); + }); + + it('returns 404 when not found', async () => { + const { getConnection } = TaskManagementController(makeContext()); + const res = await getConnection({ params: { organizationId: ORG_ID, connectionId: CONN_ID } }); + expect(res.status).to.equal(404); + }); + + it('returns 404 on org mismatch', async () => { + const conn = makeConnection({ getOrganizationId: () => 'different-org-id-12345-aabb' }); + const ctx = makeContext({ + dataAccess: { TaskManagementConnection: { findById: sinon.stub().resolves(conn) } }, + }); + const { getConnection } = TaskManagementController(ctx); + const res = await getConnection({ params: { organizationId: ORG_ID, connectionId: CONN_ID } }); + expect(res.status).to.equal(404); + }); + + it('returns 500 on collection error', async () => { + const ctx = makeContext(); + ctx.dataAccess.TaskManagementConnection.findById.rejects(new Error('db error')); + const { getConnection } = TaskManagementController(ctx); + const res = await getConnection({ params: { organizationId: ORG_ID, connectionId: CONN_ID } }); + expect(res.status).to.equal(500); + }); + + it('returns serialized connection', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { TaskManagementConnection: { findById: sinon.stub().resolves(conn) } }, + }); + const { getConnection } = TaskManagementController(ctx); + const res = await getConnection({ params: { organizationId: ORG_ID, connectionId: CONN_ID } }); + expect(res.status).to.equal(200); + const body = await res.json(); + expect(body.id).to.equal(CONN_ID); + expect(body.instanceUrl).to.equal('https://mysiteurl.atlassian.net'); + }); + }); + + // ─── listTickets ───────────────────────────────────────────────────────────── + + describe('listTickets', () => { + it('returns 400 for invalid organizationId', async () => { + const { listTickets } = TaskManagementController(makeContext()); + const res = await listTickets({ params: { organizationId: 'bad' } }); + expect(res.status).to.equal(400); + }); + + it('returns 404 when organization not found', async () => { + const ctx = makeContext({ dataAccess: { Organization: { findById: sinon.stub().resolves(null) } } }); + const { listTickets } = TaskManagementController(ctx); + const res = await listTickets({ params: { organizationId: ORG_ID } }); + expect(res.status).to.equal(404); + }); + + it('returns 500 on collection error', async () => { + const ctx = makeContext(); + ctx.dataAccess.Ticket.allByOrganizationId.rejects(new Error('db error')); + const { listTickets } = TaskManagementController(ctx); + const res = await listTickets({ params: { organizationId: ORG_ID } }); + expect(res.status).to.equal(500); + }); + + it('returns empty array when no tickets', async () => { + const { listTickets } = TaskManagementController(makeContext()); + const res = await listTickets({ params: { organizationId: ORG_ID } }); + expect(res.status).to.equal(200); + expect(await res.json()).to.deep.equal([]); + }); + + it('returns tickets with suggestions bridge', async () => { + const ticket = makeTicket(); + const bridge = { + getTicketId: () => TICKET_ID, + getSuggestionId: () => SUGGESTION_ID, + }; + const ctx = makeContext({ + dataAccess: { + Ticket: { allByOrganizationId: sinon.stub().resolves([ticket]) }, + TicketSuggestion: { allByTicketIds: sinon.stub().resolves([bridge]) }, + }, + }); + const { listTickets } = TaskManagementController(ctx); + const res = await listTickets({ params: { organizationId: ORG_ID } }); + expect(res.status).to.equal(200); + const [t] = await res.json(); + expect(t.id).to.equal(TICKET_ID); + expect(t.suggestions).to.deep.equal([SUGGESTION_ID]); + }); + + it('returns tickets with empty suggestions when bridge load fails', async () => { + const ticket = makeTicket(); + const ctx = makeContext({ + dataAccess: { + Ticket: { allByOrganizationId: sinon.stub().resolves([ticket]) }, + TicketSuggestion: { allByTicketIds: sinon.stub().rejects(new Error('bridge error')) }, + }, + }); + const { listTickets } = TaskManagementController(ctx); + const res = await listTickets({ params: { organizationId: ORG_ID } }); + expect(res.status).to.equal(200); + const [t] = await res.json(); + expect(t.suggestions).to.deep.equal([]); + }); + }); + + // ─── getTicketBySuggestion ─────────────────────────────────────────────────── + + describe('getTicketBySuggestion', () => { + it('returns 400 for invalid organizationId', async () => { + const { getTicketBySuggestion } = TaskManagementController(makeContext()); + const res = await getTicketBySuggestion({ params: { organizationId: 'bad', suggestionId: SUGGESTION_ID } }); + expect(res.status).to.equal(400); + }); + + it('returns 400 when suggestionId is empty', async () => { + const { getTicketBySuggestion } = TaskManagementController(makeContext()); + const res = await getTicketBySuggestion({ params: { organizationId: ORG_ID, suggestionId: '' } }); + expect(res.status).to.equal(400); + }); + + it('returns 404 when organization not found', async () => { + const ctx = makeContext({ dataAccess: { Organization: { findById: sinon.stub().resolves(null) } } }); + const { getTicketBySuggestion } = TaskManagementController(ctx); + const res = await getTicketBySuggestion({ params: { organizationId: ORG_ID, suggestionId: SUGGESTION_ID } }); + expect(res.status).to.equal(404); + }); + + it('returns 404 when bridge not found', async () => { + const { getTicketBySuggestion } = TaskManagementController(makeContext()); + const res = await getTicketBySuggestion({ params: { organizationId: ORG_ID, suggestionId: SUGGESTION_ID } }); + expect(res.status).to.equal(404); + }); + + it('returns 500 on bridge lookup error', async () => { + const ctx = makeContext(); + ctx.dataAccess.TicketSuggestion.findBySuggestionId.rejects(new Error('db error')); + const { getTicketBySuggestion } = TaskManagementController(ctx); + const res = await getTicketBySuggestion({ params: { organizationId: ORG_ID, suggestionId: SUGGESTION_ID } }); + expect(res.status).to.equal(500); + }); + + it('returns 500 on ticket load error', async () => { + const bridge = makeBridge(); + const ctx = makeContext({ + dataAccess: { + TicketSuggestion: { findBySuggestionId: sinon.stub().resolves(bridge) }, + Ticket: { findById: sinon.stub().rejects(new Error('db error')) }, + }, + }); + const { getTicketBySuggestion } = TaskManagementController(ctx); + const res = await getTicketBySuggestion({ params: { organizationId: ORG_ID, suggestionId: SUGGESTION_ID } }); + expect(res.status).to.equal(500); + }); + + it('returns 404 on org mismatch', async () => { + const bridge = makeBridge(); + const ticket = makeTicket({ getOrganizationId: () => 'other-org-id-1234-aaaa-bbbbbbbbbbbb' }); + const ctx = makeContext({ + dataAccess: { + TicketSuggestion: { findBySuggestionId: sinon.stub().resolves(bridge) }, + Ticket: { findById: sinon.stub().resolves(ticket) }, + }, + }); + const { getTicketBySuggestion } = TaskManagementController(ctx); + const res = await getTicketBySuggestion({ params: { organizationId: ORG_ID, suggestionId: SUGGESTION_ID } }); + expect(res.status).to.equal(404); + }); + + it('returns ticket with suggestion info', async () => { + const bridge = makeBridge(); + const ticket = makeTicket(); + const ctx = makeContext({ + dataAccess: { + TicketSuggestion: { findBySuggestionId: sinon.stub().resolves(bridge) }, + Ticket: { findById: sinon.stub().resolves(ticket) }, + }, + }); + const { getTicketBySuggestion } = TaskManagementController(ctx); + const res = await getTicketBySuggestion({ params: { organizationId: ORG_ID, suggestionId: SUGGESTION_ID } }); + expect(res.status).to.equal(200); + const body = await res.json(); + expect(body.id).to.equal(TICKET_ID); + expect(body.suggestionId).to.equal(SUGGESTION_ID); + expect(body.opportunityId).to.equal(OPPORTUNITY_ID); + }); + }); + + // ─── listTicketsByOpportunity ──────────────────────────────────────────────── + + describe('listTicketsByOpportunity', () => { + it('returns 400 for invalid organizationId', async () => { + const { listTicketsByOpportunity } = TaskManagementController(makeContext()); + const res = await listTicketsByOpportunity({ params: { organizationId: 'bad', opportunityId: OPPORTUNITY_ID } }); + expect(res.status).to.equal(400); + }); + + it('returns 400 when opportunityId is empty', async () => { + const { listTicketsByOpportunity } = TaskManagementController(makeContext()); + const res = await listTicketsByOpportunity({ params: { organizationId: ORG_ID, opportunityId: '' } }); + expect(res.status).to.equal(400); + }); + + it('returns 404 when organization not found', async () => { + const ctx = makeContext({ dataAccess: { Organization: { findById: sinon.stub().resolves(null) } } }); + const { listTicketsByOpportunity } = TaskManagementController(ctx); + const res = await listTicketsByOpportunity({ params: { organizationId: ORG_ID, opportunityId: OPPORTUNITY_ID } }); + expect(res.status).to.equal(404); + }); + + it('returns 500 on ticket lookup error', async () => { + const ctx = makeContext({ + dataAccess: { Ticket: { allByOpportunityId: sinon.stub().rejects(new Error('db error')) } }, + }); + const { listTicketsByOpportunity } = TaskManagementController(ctx); + const res = await listTicketsByOpportunity({ params: { organizationId: ORG_ID, opportunityId: OPPORTUNITY_ID } }); + expect(res.status).to.equal(500); + }); + + it('returns empty array when no tickets found', async () => { + const { listTicketsByOpportunity } = TaskManagementController(makeContext()); + const res = await listTicketsByOpportunity({ params: { organizationId: ORG_ID, opportunityId: OPPORTUNITY_ID } }); + expect(res.status).to.equal(200); + expect(await res.json()).to.deep.equal([]); + }); + + it('returns all matching tickets with suggestions via TicketSuggestion model', async () => { + const ticket = makeTicket(); + const bridgeRow = { getTicketId: () => TICKET_ID, getSuggestionId: () => SUGGESTION_ID }; + const ctx = makeContext({ + dataAccess: { + Ticket: { allByOpportunityId: sinon.stub().resolves([ticket]) }, + TicketSuggestion: { allByTicketIds: sinon.stub().resolves([bridgeRow]) }, + }, + }); + const { listTicketsByOpportunity } = TaskManagementController(ctx); + const res = await listTicketsByOpportunity({ params: { organizationId: ORG_ID, opportunityId: OPPORTUNITY_ID } }); + expect(res.status).to.equal(200); + const [t] = await res.json(); + expect(t.id).to.equal(TICKET_ID); + expect(t.suggestions).to.deep.equal([SUGGESTION_ID]); + }); + + it('returns multiple tickets for same opportunity', async () => { + const TICKET_ID_2 = 'dddddddd-eeee-ffff-0000-aaaaaaaaaaaa'; + const ticket1 = makeTicket(); + const ticket2 = makeTicket({ getId: () => TICKET_ID_2 }); + const ctx = makeContext({ + dataAccess: { + Ticket: { allByOpportunityId: sinon.stub().resolves([ticket1, ticket2]) }, + TicketSuggestion: { allByTicketIds: sinon.stub().resolves([]) }, + }, + }); + const { listTicketsByOpportunity } = TaskManagementController(ctx); + const res = await listTicketsByOpportunity({ params: { organizationId: ORG_ID, opportunityId: OPPORTUNITY_ID } }); + expect(res.status).to.equal(200); + const body = await res.json(); + expect(body).to.have.length(2); + expect(body.map((t) => t.id)).to.deep.equal([TICKET_ID, TICKET_ID_2]); + }); + + it('returns tickets with empty suggestions when bridge load fails', async () => { + const ticket = makeTicket(); + const ctx = makeContext({ + dataAccess: { + Ticket: { allByOpportunityId: sinon.stub().resolves([ticket]) }, + TicketSuggestion: { allByTicketIds: sinon.stub().rejects(new Error('bridge err')) }, + }, + }); + const { listTicketsByOpportunity } = TaskManagementController(ctx); + const res = await listTicketsByOpportunity({ params: { organizationId: ORG_ID, opportunityId: OPPORTUNITY_ID } }); + expect(res.status).to.equal(200); + const [t] = await res.json(); + expect(t.suggestions).to.deep.equal([]); + }); + + it('filters out tickets belonging to a different organization', async () => { + const matchingTicket = makeTicket(); + const otherOrgTicket = makeTicket({ + getId: () => 'ffffffff-0000-0000-0000-000000000000', + getOrganizationId: () => 'bbbbbbbb-0000-0000-0000-000000000000', + }); + const ctx = makeContext({ + dataAccess: { + Ticket: { allByOpportunityId: sinon.stub().resolves([matchingTicket, otherOrgTicket]) }, + TicketSuggestion: { allByTicketIds: sinon.stub().resolves([]) }, + }, + }); + const { listTicketsByOpportunity } = TaskManagementController(ctx); + const res = await listTicketsByOpportunity({ params: { organizationId: ORG_ID, opportunityId: OPPORTUNITY_ID } }); + expect(res.status).to.equal(200); + const body = await res.json(); + expect(body).to.have.length(1); + expect(body[0].id).to.equal(TICKET_ID); + }); + }); + + // ─── createTicket ───────────────────────────────────────────────────────────── + + describe('createTicket', () => { + function makeReqCtx(overrides = {}) { + // When overrides.data is provided it completely replaces the default body + // so individual tests can omit fields (e.g. no summary) without the default leaking in. + const data = overrides.data !== undefined + ? overrides.data + : { + summary: 'Fix the thing', projectKey: 'PROJ', connectionId: CONN_ID, opportunityId: OPPORTUNITY_ID, + }; + return { + params: { organizationId: ORG_ID, provider: PROVIDER, ...(overrides.params ?? {}) }, + data, + pathInfo: { headers: {}, ...(overrides.pathInfo ?? {}) }, + attributes: { + authInfo: { + getProfile: () => ({ email: 'ims-user-1@example.com' }), + }, + ...(overrides.attributes ?? {}), + }, + }; + } + + it('returns 400 for invalid organizationId', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ params: { organizationId: 'bad', provider: PROVIDER } })); + expect(res.status).to.equal(400); + }); + + it('returns 400 when provider is empty', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ params: { organizationId: ORG_ID, provider: '' } })); + expect(res.status).to.equal(400); + }); + + it('returns 400 when provider is unsupported', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ params: { organizationId: ORG_ID, provider: 'linear' } })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('Unsupported provider'); + }); + + it('returns 404 when organization not found', async () => { + const ctx = makeContext({ dataAccess: { Organization: { findById: sinon.stub().resolves(null) } } }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(404); + }); + + it('returns 400 when connectionId is missing', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ data: { summary: 'Fix', projectKey: 'PROJ' } })); + expect(res.status).to.equal(400); + }); + + it('returns 400 when body has no summary', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ data: { projectKey: 'PROJ', connectionId: CONN_ID } })); + expect(res.status).to.equal(400); + }); + + it('returns 400 when neither opportunityId nor suggestionIds is provided', async () => { + const { createTicket } = TaskManagementController(makeContext({ + dataAccess: { TaskManagementConnection: { findById: sinon.stub().resolves(makeConnection()) } }, + })); + const res = await createTicket(makeReqCtx({ data: { summary: 'Fix', projectKey: 'P', connectionId: CONN_ID } })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('opportunityId or suggestionIds'); + }); + + it('returns 400 when body has no projectKey', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ data: { summary: 'Fix it', connectionId: CONN_ID } })); + expect(res.status).to.equal(400); + }); + + it('returns 400 for grouped mode with no suggestionIds', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'P', mode: 'grouped', connectionId: CONN_ID, + }, + })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('requires at least one suggestionId'); + }); + + it('returns 400 for unknown mode (message lists supported modes)', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'P', mode: 'batch', connectionId: CONN_ID, + }, + })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('individual'); + expect(body.message).to.include('grouped'); + }); + + it('returns 400 when attachment provided in individual batch mode (N>1 suggestions)', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const content = Buffer.from('hello').toString('base64'); + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', + projectKey: 'P', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2], + attachments: [{ content, mimeType: 'text/plain', filename: 'note.txt' }], + }, + })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('individual batch mode'); + }); + + it('returns 400 when suggestionIds exceeds max for individual mode (>15)', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const suggestionIds = Array.from({ length: 16 }, (_, i) => `id-${i}`); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'P', mode: 'individual', connectionId: CONN_ID, suggestionIds, + }, + })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('at most 15'); + expect(body.message).to.include("'individual'"); + }); + + it('returns 400 when suggestionIds exceeds max for grouped mode (>1500)', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const suggestionIds = Array.from({ length: 1501 }, (_, i) => `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'P', mode: 'grouped', connectionId: CONN_ID, suggestionIds, + }, + })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('at most 1500'); + expect(body.message).to.include("'grouped'"); + }); + + it('returns 404 when no active connection found', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(404); + }); + + it('returns 409 with connection_reauth_required when connection requires reauth', async () => { + const conn = makeConnection({ getStatus: () => 'requires_reauth' }); + const ctx = makeContext({ + dataAccess: { TaskManagementConnection: { findById: sinon.stub().resolves(conn) } }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(409); + const body = await res.json(); + expect(body.message).to.equal('connection_reauth_required'); + }); + + it('returns 404 when connection is disabled (non-active, non-reauth status)', async () => { + const conn = makeConnection({ getStatus: () => 'disabled' }); + const ctx = makeContext({ + dataAccess: { TaskManagementConnection: { findById: sinon.stub().resolves(conn) } }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(404); + }); + + it('returns 500 on connection load error', async () => { + const ctx = makeContext(); + ctx.dataAccess.TaskManagementConnection.findById.rejects(new Error('db')); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(500); + }); + + it('returns 409 when any suggestionId is already ticketed', async () => { + const conn = makeConnection(); + const bridgeRow = { getSuggestionId: () => SUGGESTION_ID }; + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + TicketSuggestion: { allBySuggestionIds: sinon.stub().resolves([bridgeRow]) }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'PROJ', connectionId: CONN_ID, suggestionIds: [SUGGESTION_ID], + }, + })); + expect(res.status).to.equal(409); + const body = await res.json(); + expect(body.message).to.include('already ticketed'); + }); + + it('returns 500 when TicketSuggestion bridge lookup throws', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + TicketSuggestion: { allBySuggestionIds: sinon.stub().rejects(new Error('db error')) }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'PROJ', connectionId: CONN_ID, suggestionIds: [SUGGESTION_ID], + }, + })); + expect(res.status).to.equal(500); + }); + + it('returns 409 when Jira client throws 401', async () => { + const conn = makeConnection(); + const err = Object.assign(new Error('Unauthorized'), { status: 401 }); + const ticketClientStub = { createTicket: sinon.stub().rejects(err) }; + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + + // Re-mock TicketClientFactory for this test + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns(ticketClientStub) }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(409); + expect(conn.markRequiresReauth).to.not.have.been.called; + }); + + it('returns 409 when OAuthCredentialManager throws requires re-authorization', async () => { + const conn = makeConnection(); + const err = new Error('OAuth token refresh failed — connection requires re-authorization'); + const ticketClientStub = { createTicket: sinon.stub().rejects(err) }; + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns(ticketClientStub) }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(409); + expect(conn.markRequiresReauth).to.have.been.calledOnce; + }); + + it('passes instanceUrl to TicketClientFactory (required for JiraCloudClient)', async () => { + const conn = makeConnection(); + let capturedConnObj; + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: (connObj) => { + capturedConnObj = connObj; + return { + createTicket: sinon.stub().resolves({ + ticketId: 'PROJ-1', ticketKey: 'PROJ-1', ticketUrl: 'https://x.atlassian.net/browse/PROJ-1', ticketStatus: 'To Do', + }), + }; + }, + }, + }, + })).default; + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + const { createTicket } = Ctrl(ctx); + await createTicket(makeReqCtx()); + expect(capturedConnObj.instanceUrl).to.equal('https://mysiteurl.atlassian.net'); + }); + + it('passes priority, dueDate, components, and parent through to ticketClient.createTicket', async () => { + const conn = makeConnection(); + const createTicketStub = sinon.stub().resolves({ + ticketId: 'P-1', ticketKey: 'P-1', ticketUrl: 'https://x.net/P-1', ticketStatus: 'To Do', + }); + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns({ createTicket: createTicketStub }) }, + }, + })).default; + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + Ticket: { create: sinon.stub().resolves(makeTicket()) }, + TicketSuggestion: { create: sinon.stub().resolves() }, + }, + }); + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix bug', + projectKey: 'ASO', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID], + priority: 'High', + dueDate: '2026-12-31', + components: ['Frontend', 'API'], + parent: 'ASO-42', + }, + })); + expect(res.status).to.equal(201); + const callArgs = createTicketStub.firstCall.args[0]; + expect(callArgs.priority).to.equal('High'); + expect(callArgs.dueDate).to.equal('2026-12-31'); + expect(callArgs.components).to.deep.equal(['Frontend', 'API']); + expect(callArgs.parent).to.equal('ASO-42'); + }); + + it('passes field pass-through in grouped mode', async () => { + const conn = makeConnection(); + const createTicketStub = sinon.stub().resolves({ + ticketId: 'P-1', ticketKey: 'P-1', ticketUrl: 'https://x.net/P-1', ticketStatus: 'To Do', + }); + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns({ createTicket: createTicketStub }) }, + }, + })).default; + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + Ticket: { create: sinon.stub().resolves(makeTicket()) }, + TicketSuggestion: { create: sinon.stub().resolves() }, + }, + }); + const { createTicket } = Ctrl(ctx); + const s2 = '22222222-2222-2222-2222-222222222222'; + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped', + projectKey: 'ASO', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID, s2], + priority: 'Low', + dueDate: '2027-01-15', + components: ['Backend'], + parent: 'ASO-100', + }, + })); + expect(res.status).to.equal(201); + const callArgs = createTicketStub.firstCall.args[0]; + expect(callArgs.priority).to.equal('Low'); + expect(callArgs.dueDate).to.equal('2027-01-15'); + expect(callArgs.components).to.deep.equal(['Backend']); + expect(callArgs.parent).to.equal('ASO-100'); + }); + + it('passes field pass-through in batch mode', async () => { + const conn = makeConnection(); + const createTicketStub = sinon.stub().resolves({ + ticketId: 'P-1', ticketKey: 'P-1', ticketUrl: 'https://x.net/P-1', ticketStatus: 'To Do', + }); + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns({ createTicket: createTicketStub }) }, + }, + })).default; + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + Ticket: { create: sinon.stub().resolves(makeTicket()) }, + TicketSuggestion: { create: sinon.stub().resolves() }, + }, + }); + const { createTicket } = Ctrl(ctx); + const s2 = '33333333-3333-3333-3333-333333333333'; + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Batch', + projectKey: 'ASO', + connectionId: CONN_ID, + mode: 'individual', + suggestionIds: [SUGGESTION_ID, s2], + priority: 'Medium', + components: ['Infra'], + }, + })); + expect(res.status).to.equal(207); + const callArgs = createTicketStub.firstCall.args[0]; + expect(callArgs.priority).to.equal('Medium'); + expect(callArgs.components).to.deep.equal(['Infra']); + }); + + it('returns 500 on generic ticket client error', async () => { + const conn = makeConnection(); + const err = new Error('timeout'); + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns({ createTicket: sinon.stub().rejects(err) }) }, + }, + })).default; + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(500); + }); + + it('returns 500 when Ticket.create fails', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Ticket: { + create: sinon.stub().rejects(new Error('db error')), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().resolves({ + ticketId: 'P-1', ticketKey: 'P-1', ticketUrl: 'https://x.net/P-1', ticketStatus: 'To Do', + }), + }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(500); + }); + + it('creates ticket and bridge, returns 201', async () => { + const conn = makeConnection(); + const ticket = makeTicket(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Ticket: { + create: sinon.stub().resolves(ticket), + }, + TicketSuggestion: { + create: sinon.stub().resolves(), + }, + Suggestion: { + findById: sinon.stub().resolves(makeSuggestion()), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().resolves({ + ticketId: 'PROJ-42', ticketKey: 'PROJ-42', ticketUrl: 'https://x.net/PROJ-42', ticketStatus: 'To Do', + }), + }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix it', projectKey: 'PROJ', connectionId: CONN_ID, suggestionIds: [SUGGESTION_ID], opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(201); + const body = await res.json(); + expect(body.id).to.equal(TICKET_ID); + expect(body.suggestionId).to.equal(SUGGESTION_ID); + expect(ctx.dataAccess.TicketSuggestion.create).to.have.been.calledOnce; + }); + + it('returns 500 when TicketSuggestion.create fails with non-unique error', async () => { + const conn = makeConnection(); + const ticket = makeTicket(); + const err = new Error('foreign key constraint violation'); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Ticket: { create: sinon.stub().resolves(ticket) }, + TicketSuggestion: { create: sinon.stub().rejects(err) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().resolves({ + ticketId: 'P-1', ticketKey: 'P-1', ticketUrl: 'https://x.net/P-1', ticketStatus: 'To Do', + }), + }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'P', connectionId: CONN_ID, suggestionIds: [SUGGESTION_ID], + }, + })); + expect(res.status).to.equal(500); + }); + + it('returns 409 on duplicate TicketSuggestion (unique constraint)', async () => { + const conn = makeConnection(); + const ticket = makeTicket(); + const err = Object.assign(new Error('duplicate key value violates unique constraint'), { code: '23505' }); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Ticket: { create: sinon.stub().resolves(ticket) }, + TicketSuggestion: { create: sinon.stub().rejects(err) }, + Suggestion: { + findById: sinon.stub().resolves(makeSuggestion()), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().resolves({ + ticketId: 'P-1', ticketKey: 'P-1', ticketUrl: 'https://x.net/P-1', ticketStatus: 'To Do', + }), + }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'P', connectionId: CONN_ID, suggestionIds: [SUGGESTION_ID], + }, + })); + expect(res.status).to.equal(409); + }); + + it('returns 201 without bridge when no suggestionIds provided', async () => { + const conn = makeConnection(); + const ticket = makeTicket(); + const bridgeCreate = sinon.stub().resolves(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Ticket: { create: sinon.stub().resolves(ticket) }, + TicketSuggestion: { create: bridgeCreate }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().resolves({ + ticketId: 'P-1', ticketKey: 'P-1', ticketUrl: 'https://x.net/P-1', ticketStatus: 'To Do', + }), + }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(201); + expect(bridgeCreate).to.not.have.been.called; + }); + + // ── Idempotency-Key enforcement ───────────────────────────────────────── + + it('returns 500 when idempotency key lookup fails', async () => { + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(makeConnection()) }, + IdempotencyKey: { + findActiveKey: sinon.stub().rejects(new Error('db unavailable')), + }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(500); + }); + + it('returns cached response when idempotency key is completed', async () => { + const cachedBody = { id: TICKET_ID, ticketKey: 'PROJ-1' }; + const cachedEntry = { + getStatus: () => 'completed', + getResponse: () => ({ statusCode: 201, body: cachedBody }), + getId: () => 'idem-1', + getCreatedAt: () => '2026-01-01T00:00:00Z', + }; + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(makeConnection()) }, + IdempotencyKey: { findActiveKey: sinon.stub().resolves(cachedEntry) }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(201); + const body = await res.json(); + expect(body.id).to.equal(TICKET_ID); + }); + + it('returns 409 when idempotency key is processing', async () => { + const processingEntry = { + getStatus: () => 'processing', + getResponse: () => null, + getId: () => 'idem-1', + getCreatedAt: () => '2026-01-01T00:00:00Z', + }; + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(makeConnection()) }, + IdempotencyKey: { findActiveKey: sinon.stub().resolves(processingEntry) }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(409); + const body = await res.json(); + expect(body.message).to.include('already in flight'); + }); + + it('returns cached error response when idempotency key is failed', async () => { + const cachedBody = { message: 'Failed to create ticket' }; + const cachedEntry = { + getStatus: () => 'failed', + getResponse: () => ({ statusCode: 500, body: cachedBody }), + getId: () => 'idem-1', + getCreatedAt: () => '2026-01-01T00:00:00Z', + }; + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(makeConnection()) }, + IdempotencyKey: { findActiveKey: sinon.stub().resolves(cachedEntry) }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(500); + }); + + it('returns 409 when idempotency key insert hits unique constraint race', async () => { + const conn = makeConnection(); + const uniqueError = new Error('duplicate key value violates unique constraint'); + uniqueError.code = '23505'; + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + IdempotencyKey: { + findActiveKey: sinon.stub().resolves(null), + create: sinon.stub().rejects(uniqueError), + }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(409); + const body = await res.json(); + expect(body.message).to.include('already in flight'); + }); + + it('returns 500 when idempotency key insert fails with non-constraint error', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + IdempotencyKey: { + findActiveKey: sinon.stub().resolves(null), + create: sinon.stub().rejects(new Error('connection reset')), + }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(500); + }); + + it('treats expired idempotency key as absent (proceeds to create)', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + expect(res.status).to.equal(201); + }); + + // ── Explicit connectionId resolution ──────────────────────────────────── + + it('resolves explicit connectionId and creates ticket', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix it', + projectKey: 'PROJ', + connectionId: CONN_ID, + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(201); + }); + + it('returns 404 when explicit connectionId is not found', async () => { + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(null), + }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix it', + projectKey: 'PROJ', + connectionId: CONN_ID, + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(404); + }); + + it('returns 400 when explicit connectionId is not a valid UUID', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix it', + projectKey: 'PROJ', + connectionId: 'not-a-uuid', + }, + })); + expect(res.status).to.equal(400); + }); + + // ── Suggestion existence validation ──────────────────────────────────── + + it('returns 404 when primarySuggestionId is not found', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Suggestion: { + findById: sinon.stub().resolves(null), + }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix it', projectKey: 'PROJ', connectionId: CONN_ID, suggestionIds: [SUGGESTION_ID], + }, + })); + expect(res.status).to.equal(404); + const body = await res.json(); + expect(body.message).to.include('not found'); + }); + + it('returns 500 on Suggestion lookup error', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Suggestion: { + findById: sinon.stub().rejects(new Error('db error')), + }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix it', projectKey: 'PROJ', connectionId: CONN_ID, suggestionIds: [SUGGESTION_ID], + }, + })); + expect(res.status).to.equal(500); + }); + + // ── Attachment validation (spec §30) ─────────────────────────────────────── + + it('returns 400 when attachment is missing content', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'PROJ', connectionId: CONN_ID, attachments: [{ mimeType: 'image/png', filename: 'a.png' }], + }, + })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('attachment must have'); + }); + + it('returns 400 when attachment is missing mimeType', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'PROJ', connectionId: CONN_ID, attachments: [{ content: Buffer.from('x').toString('base64'), filename: 'a.png' }], + }, + })); + expect(res.status).to.equal(400); + }); + + it('returns 400 when attachment is missing filename', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'PROJ', connectionId: CONN_ID, attachments: [{ content: Buffer.from('x').toString('base64'), mimeType: 'image/png' }], + }, + })); + expect(res.status).to.equal(400); + }); + + it('returns 400 when attachment content is empty after decoding', async () => { + const { createTicket } = TaskManagementController(makeContext()); + // base64 of empty string decodes to zero bytes + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'PROJ', connectionId: CONN_ID, attachments: [{ content: '', mimeType: 'image/png', filename: 'a.png' }], + }, + })); + expect(res.status).to.equal(400); + }); + + it('returns 400 when attachment exceeds 3 MB', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const oversized = Buffer.alloc(3 * 1024 * 1024 + 1, 0x00); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', projectKey: 'PROJ', connectionId: CONN_ID, attachments: [{ content: oversized.toString('base64'), mimeType: 'image/png', filename: 'big.png' }], + }, + })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('exceeds maximum size'); + }); + + it('returns 400 when attachment mimeType is not in allowlist', async () => { + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', + projectKey: 'PROJ', + connectionId: CONN_ID, + attachments: [{ content: Buffer.from('x').toString('base64'), mimeType: 'application/javascript', filename: 'evil.js' }], + }, + })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('mimeType'); + }); + + it('does not reject attachment with path traversal filename (sanitized, not blocked)', async () => { + // Path traversal in filename is sanitized server-side — the request itself must not 400. + // The sanitized name is forwarded to the ticket client which applies its own sanitization. + // Use an invalid mimeType to short-circuit before any Jira call — we only test that the + // filename itself does not cause a 400 at the shape-validation step. + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', + projectKey: 'PROJ', + connectionId: CONN_ID, + attachments: [{ content: Buffer.from('x').toString('base64'), mimeType: 'application/octet-stream', filename: '../../../etc/passwd' }], + }, + })); + // Blocked by MIME allowlist (400), not by filename shape — confirms filename did not trigger its own rejection + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('mimeType'); + }); + + it('creates ticket with attachment and returns 201', async () => { + const conn = makeConnection(); + const ticket = makeTicket(); + const uploadStub = sinon.stub().resolves(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Ticket: { create: sinon.stub().resolves(ticket) }, + TicketSuggestion: { create: sinon.stub().resolves() }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().resolves({ + ticketId: 'PROJ-42', ticketKey: 'PROJ-42', ticketUrl: 'https://x.net/PROJ-42', ticketStatus: 'To Do', + }), + uploadAttachment: uploadStub, + }), + }, + }, + })).default; + + const validPng = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG magic bytes + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix it', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID], + attachments: [{ content: validPng.toString('base64'), mimeType: 'image/png', filename: 'screenshot.png' }], + }, + })); + + expect(res.status).to.equal(201); + expect(uploadStub).to.have.been.calledOnce; + const body = await res.json(); + expect(body).to.not.have.property('attachmentWarning'); + }); + + it('returns 201 with attachmentWarning when upload fails (partial success)', async () => { + const conn = makeConnection(); + const ticket = makeTicket(); + const uploadStub = sinon.stub().rejects(new Error('Jira returned 403')); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Ticket: { create: sinon.stub().resolves(ticket) }, + TicketSuggestion: { create: sinon.stub().resolves() }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().resolves({ + ticketId: 'PROJ-42', ticketKey: 'PROJ-42', ticketUrl: 'https://x.net/PROJ-42', ticketStatus: 'To Do', + }), + uploadAttachment: uploadStub, + }), + }, + }, + })).default; + + const validPng = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix it', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID], + attachments: [{ content: validPng.toString('base64'), mimeType: 'image/png', filename: 'screenshot.png' }], + }, + })); + + expect(res.status).to.equal(201); + const body = await res.json(); + expect(body).to.have.property('attachmentWarning'); + expect(body.attachmentWarning).to.include('attachment upload failed'); + }); + + // ── Grouped mode happy path ───────────────────────────────────────────── + + it('grouped mode creates one ticket linked to all suggestionIds, returns 201', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const conn = makeConnection(); + const ticket = makeTicket(); + const bridgeCreate = sinon.stub().resolves(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Ticket: { create: sinon.stub().resolves(ticket) }, + TicketSuggestion: { create: bridgeCreate }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const createTicketStub = sinon.stub().resolves({ + ticketId: 'PROJ-42', ticketKey: 'PROJ-42', ticketUrl: 'https://x.net/PROJ-42', ticketStatus: 'To Do', + }); + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ createTicket: createTicketStub }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped fix', + projectKey: 'PROJ', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID, sid2], + opportunityId: OPPORTUNITY_ID, + }, + })); + + expect(res.status).to.equal(201); + const body = await res.json(); + expect(body.id).to.equal(TICKET_ID); + expect(body.suggestionIds).to.deep.equal([SUGGESTION_ID, sid2]); + expect(body).to.not.have.property('linkWarnings'); + expect(createTicketStub).to.have.been.calledOnce; + expect(bridgeCreate).to.have.been.calledTwice; + }); + + // ── Individual batch happy path (N>1 → 207 Multi-Status) ──────────────── + + it('individual batch mode creates N tickets, returns 207 with per-suggestion results', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const conn = makeConnection(); + const ticket1 = makeTicket(); + const ticket2 = makeTicket({ + getId: () => 'ffffffff-aaaa-bbbb-cccc-dddddddddddd', + getTicketKey: () => 'PROJ-43', + getTicketUrl: () => 'https://mysite.atlassian.net/browse/PROJ-43', + }); + + const ticketCreate = sinon.stub(); + ticketCreate.onFirstCall().resolves(ticket1); + ticketCreate.onSecondCall().resolves(ticket2); + + const bridgeCreate = sinon.stub().resolves(); + const suggestionFindById = sinon.stub(); + suggestionFindById.withArgs(SUGGESTION_ID).resolves(makeSuggestion()); + suggestionFindById.withArgs(sid2).resolves(makeSuggestion({ getId: () => sid2 })); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + Ticket: { create: ticketCreate }, + TicketSuggestion: { create: bridgeCreate }, + Suggestion: { findById: suggestionFindById }, + }, + }); + + const jiraCreateTicket = sinon.stub(); + jiraCreateTicket.onFirstCall().resolves({ + ticketId: 'PROJ-42', ticketKey: 'PROJ-42', ticketUrl: 'https://x.net/PROJ-42', ticketStatus: 'To Do', + }); + jiraCreateTicket.onSecondCall().resolves({ + ticketId: 'PROJ-43', ticketKey: 'PROJ-43', ticketUrl: 'https://x.net/PROJ-43', ticketStatus: 'To Do', + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ createTicket: jiraCreateTicket }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix both', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2], + opportunityId: OPPORTUNITY_ID, + }, + })); + + expect(res.status).to.equal(207); + const body = await res.json(); + expect(body.results).to.have.lengthOf(2); + expect(body.results[0].status).to.equal(201); + expect(body.results[0].suggestionId).to.equal(SUGGESTION_ID); + expect(body.results[0].ticket).to.have.property('ticketKey'); + expect(body.results[1].status).to.equal(201); + expect(body.results[1].suggestionId).to.equal(sid2); + expect(jiraCreateTicket).to.have.been.calledTwice; + expect(bridgeCreate).to.have.been.calledTwice; + }); + + // ── Branch 2: TicketSuggestion.allByTicketId throws inside listTickets map ── + // (covered under createTicket section for proximity; branch lives in listTickets) + + // ── Branch 3: attachments field is not an array — coerced to [] ───────────── + it('treats non-array attachments as empty — proceeds without attachment', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + // Pass attachments as a plain object (not an array) — should be coerced to [] + const res = await createTicket(makeReqCtx({ + data: { + summary: 'No array attachments', + projectKey: 'PROJ', + connectionId: CONN_ID, + attachments: { content: 'abc', mimeType: 'image/png', filename: 'x.png' }, + opportunityId: OPPORTUNITY_ID, + }, + })); + // Proceeds normally — non-array is treated as "no attachment" + expect(res.status).to.equal(201); + }); + + // ── Branch 4: attachment content decodes to empty buffer — returns 400 ────── + // Node's Buffer.from does NOT throw on invalid base64 — it silently skips bad + // chars and may return an empty buffer. '====' (padding only) decodes to 0 bytes. + it('returns 400 when attachment content decodes to empty buffer', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Bad base64', + projectKey: 'PROJ', + connectionId: CONN_ID, + // '====' is 4 padding chars with no data — decodes to 0 bytes, passes hasText + attachments: [{ content: '====', mimeType: 'image/png', filename: 'x.png' }], + }, + })); + // decoded.length === 0 branch (line 540-542) + expect(res.status).to.equal(400); + }); + + // ── Branch 5: Suggestion.batchGetByKeys throws in grouped mode ───────────── + it('returns 500 when Suggestion.batchGetByKeys throws in grouped mode', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { + findById: sinon.stub().resolves(makeSuggestion()), + batchGetByKeys: sinon.stub().rejects(new Error('DB timeout')), + }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped ticket', + projectKey: 'PROJ', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(500); + const body = await res.json(); + expect(body.message).to.equal('Failed to validate suggestion'); + }); + + // ── Branch 6: markIdempotencyDone update fails — only warns, does not throw ─ + it('logs warn but still returns 201 when idempotency done-update fails', async () => { + const conn = makeConnection(); + + const idempotencyEntry = { + setStatus: sinon.stub().returnsThis(), + setResponse: sinon.stub().returnsThis(), + save: sinon.stub().rejects(new Error('PG update error')), + remove: sinon.stub().resolves(), + }; + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + TicketSuggestion: { allBySuggestionIds: sinon.stub().resolves([]) }, + IdempotencyKey: { + findActiveKey: sinon.stub().resolves(null), + create: sinon.stub().resolves(idempotencyEntry), + }, + }, + }); + + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx()); + // Response succeeds — warn-only path does not bubble the error + expect(res.status).to.equal(201); + expect(ctx.log.warn).to.have.been.calledWithMatch( + sinon.match.object, + 'Failed to cache completed response in idempotency lock', + ); + }); + + // ── Branch 6b: markIdempotencyFailed delete fails — only warns, returns 409 ─ + it('logs warn but still returns 409 when markIdempotencyFailed delete fails', async () => { + const conn = makeConnection(); + + // Single idempotency insert succeeds; ticket creation throws GRANT_REVOKED + // which triggers markIdempotencyFailed; the delete rejects but error is swallowed. + const idempotencyEntry = { + setStatus: sinon.stub().returnsThis(), + setResponse: sinon.stub().returnsThis(), + save: sinon.stub().resolves(), + remove: sinon.stub().rejects(new Error('PG delete failed')), + }; + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + TicketSuggestion: { allBySuggestionIds: sinon.stub().resolves([]) }, + IdempotencyKey: { + findActiveKey: sinon.stub().resolves(null), + create: sinon.stub().resolves(idempotencyEntry), + }, + }, + }); + + // esmock the ticket client to throw GRANT_REVOKED so we reach markIdempotencyFailed + 409 + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().rejects(Object.assign(new Error('grant revoked'), { code: 'GRANT_REVOKED' })), + }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grant revoked + idem delete fail', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + // markIdempotencyFailed delete error is swallowed — 409 still returned + expect(res.status).to.equal(409); + expect(ctx.log.warn).to.have.been.called; + }); + + // ── Branch 9: per-item Suggestion not found in individual batch mode ───────── + it('individual batch: records 404 for suggestion not found mid-loop', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const conn = makeConnection(); + const ticket1 = makeTicket(); + + const ticketCreate = sinon.stub().resolves(ticket1); + const bridgeCreate = sinon.stub().resolves(); + // First suggestion (primarySuggestionId) resolves; second is not found + const suggestionFindById = sinon.stub(); + suggestionFindById.withArgs(SUGGESTION_ID).resolves(makeSuggestion()); + suggestionFindById.withArgs(sid2).resolves(null); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: ticketCreate }, + TicketSuggestion: { create: bridgeCreate }, + Suggestion: { findById: suggestionFindById }, + }, + }); + + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Batch partial miss', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(207); + const body = await res.json(); + expect(body.results).to.have.lengthOf(2); + expect(body.results[0].status).to.equal(201); + expect(body.results[1].status).to.equal(404); + expect(body.results[1].error).to.include('not found'); + }); + + // ── Branch 10a: batch reauth short-circuits remaining items ───────────────── + it('individual batch: short-circuits remaining suggestions after 401 token expiry', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const sid3 = 'dddddddd-eeee-ffff-aaaa-dddddddddddd'; + const conn = makeConnection(); + const reauthErr = Object.assign(new Error('Unauthorized'), { status: 401 }); + + const suggestionFindById = sinon.stub(); + suggestionFindById.resolves(makeSuggestion()); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: suggestionFindById }, + }, + }); + + const jiraCreateTicket = sinon.stub().rejects(reauthErr); + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns({ createTicket: jiraCreateTicket }) }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Batch reauth', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2, sid3], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(207); + const body = await res.json(); + // All 3 suggestions should be in results — first fails with reauth, remaining are short-circuited + expect(body.results).to.have.lengthOf(3); + body.results.forEach((r) => { + expect(r.status).to.equal(409); + expect(r.error).to.equal('token_refresh_required'); + }); + // markRequiresReauth is NOT called for 401 (token expiry path) + expect(conn.markRequiresReauth).to.not.have.been.called; + // Jira called only once — breaks out of loop + expect(jiraCreateTicket).to.have.been.calledOnce; + }); + + // ── Branch 10b: markRequiresReauth rejects in individual batch mode ────────── + // markRequiresReauth() failure is swallowed via .catch() — the batch still + // short-circuits and returns 207 with connection_reauth_required for all items. + // Uses 2 suggestions to exercise the batch loop (length > 1 required). + it('individual batch: returns 207 when markRequiresReauth throws (DB error swallowed)', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-bbbbbbbbbbbc'; + const conn = makeConnection({ + markRequiresReauth: sinon.stub().rejects(new Error('DB down')), + }); + const reauthErr = Object.assign(new Error('Grant revoked'), { code: 'GRANT_REVOKED' }); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ createTicket: sinon.stub().rejects(reauthErr) }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + // markRequiresReauth rejection is caught via .catch() — createTicket resolves normally. + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Reauth reject', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(207); + const body = await res.json(); + expect(body.results).to.have.lengthOf(2); + body.results.forEach((r) => { + expect(r.status).to.equal(409); + expect(r.error).to.equal('connection_reauth_required'); + }); + // markRequiresReauth was attempted once (for first suggestion) and failed silently + expect(conn.markRequiresReauth).to.have.been.calledOnce; + }); + + it('individual batch: short-circuits remaining suggestions after grant revocation', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const sid3 = 'dddddddd-eeee-ffff-aaaa-dddddddddddd'; + const conn = makeConnection(); + const grantRevokedErr = Object.assign(new Error('Grant revoked'), { code: 'GRANT_REVOKED' }); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const jiraCreateTicket = sinon.stub().rejects(grantRevokedErr); + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns({ createTicket: jiraCreateTicket }) }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Batch grant revoked', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2, sid3], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(207); + const body = await res.json(); + expect(body.results).to.have.lengthOf(3); + body.results.forEach((r) => { + expect(r.status).to.equal(409); + expect(r.error).to.equal('connection_reauth_required'); + }); + expect(conn.markRequiresReauth).to.have.been.calledOnce; + expect(jiraCreateTicket).to.have.been.calledOnce; + }); + + // ── Branch 11: connection.save() fails after Jira error in grouped mode ────── + it('grouped mode: logs warn but still returns 500 when connection.save fails after Jira error', async () => { + const conn = makeConnection({ + setErrorMessage: sinon.stub().returnsThis(), + save: sinon.stub().rejects(new Error('save failed')), + }); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().rejects(new Error('Jira upstream error')), + }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped save fail', + projectKey: 'PROJ', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + // connection.save() rejection is swallowed via .catch() — primary response is 500 from Jira failure + expect(res.status).to.equal(500); + expect(conn.save).to.have.been.called; + }); + + // ── Branch 12: connection.save() fails post-creation in grouped mode ───────── + it('grouped mode: logs warn but still returns 201 when lastUsedAt save fails', async () => { + const conn = makeConnection({ + setLastUsedAt: sinon.stub().returnsThis(), + setErrorMessage: sinon.stub().returnsThis(), + save: sinon.stub().rejects(new Error('save failed')), + }); + const ticket = makeTicket(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: sinon.stub().resolves(ticket) }, + TicketSuggestion: { create: sinon.stub().resolves() }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped lastUsedAt fail', + projectKey: 'PROJ', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + // save() rejection is swallowed via .catch() — ticket is created successfully + expect(res.status).to.equal(201); + expect(conn.save).to.have.been.called; + expect(ctx.log.warn).to.have.been.called; + }); + + // ── Branch 13: isDuplicate branch in grouped bridge loop ───────────────────── + it('grouped mode: records linkWarning (not error) when TicketSuggestion.create hits unique constraint', async () => { + const conn = makeConnection(); + const ticket = makeTicket(); + const uniqueErr = Object.assign(new Error('duplicate key value violates unique constraint'), { code: '23505' }); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: sinon.stub().resolves(ticket) }, + TicketSuggestion: { create: sinon.stub().rejects(uniqueErr) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped dup bridge', + projectKey: 'PROJ', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(201); + const body = await res.json(); + // Duplicate bridge rows surface as linkWarnings, not errors + expect(body.linkWarnings).to.be.an('array').with.lengthOf(1); + expect(body.linkWarnings[0]).to.include('already been linked'); + }); + + // ── Branch 14 (single mode) / Branch 1 (listConnections): covered below ───── + + // ── Branch 14: connection.save() fails in single-ticket post-creation ──────── + it('single mode: logs warn but still returns 201 when lastUsedAt save fails', async () => { + const conn = makeConnection({ + setLastUsedAt: sinon.stub().returnsThis(), + setErrorMessage: sinon.stub().returnsThis(), + save: sinon.stub().rejects(new Error('save failed')), + }); + const ticket = makeTicket(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: sinon.stub().resolves(ticket) }, + TicketSuggestion: { create: sinon.stub().resolves() }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const { createTicket } = TaskManagementController(ctx); + // Single-ticket mode: one suggestionId only + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Single lastUsedAt fail', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + // save() rejection is swallowed via .catch() — ticket is created successfully + expect(res.status).to.equal(201); + expect(conn.save).to.have.been.called; + expect(ctx.log.warn).to.have.been.called; + }); + + // ── Lines 518-522: attachments.length > 1 → 400 ───────────────────────────── + it('returns 400 when more than one attachment is provided', async () => { + const content = Buffer.from('hello').toString('base64'); + const att = { content, mimeType: 'image/png', filename: 'file.png' }; + const { createTicket } = TaskManagementController(makeContext()); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Fix', + projectKey: 'PROJ', + connectionId: CONN_ID, + attachments: [att, att], + }, + })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('at most 1 item'); + }); + + // ── Lines 641-642: Suggestion not found in grouped mode → 404 ─────────────── + it('grouped mode: returns 404 when batchGetByKeys returns empty for suggestionId', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { + findById: sinon.stub().resolves(null), + batchGetByKeys: sinon.stub().resolves({ data: [], unprocessed: [] }), + }, + }, + }); + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped ticket', + projectKey: 'PROJ', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(404); + const body = await res.json(); + expect(body.message).to.include('not found'); + }); + + // ── Lines 820-823: Suggestion.findById throws in individual batch mode → 500 in results + it('individual batch: records 500 when Suggestion.findById throws mid-loop', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const conn = makeConnection(); + const ticket1 = makeTicket(); + + const suggestionFindById = sinon.stub(); + suggestionFindById.withArgs(SUGGESTION_ID).resolves(makeSuggestion()); + suggestionFindById.withArgs(sid2).rejects(new Error('DB timeout in batch')); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: sinon.stub().resolves(ticket1) }, + TicketSuggestion: { create: sinon.stub().resolves() }, + Suggestion: { findById: suggestionFindById }, + }, + }); + + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Batch suggestion throw', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(207); + const body = await res.json(); + const failedItem = body.results.find((r) => r.suggestionId === sid2); + expect(failedItem.status).to.equal(500); + expect(failedItem.error).to.equal('Failed to validate suggestion'); + }); + + // ── Lines 862-864: Non-reauth batch Jira error → 500 in results ───────────── + it('individual batch: records 500 when ticketClient.createTicket throws generic error', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const conn = makeConnection(); + const ticket1 = makeTicket(); + + const suggestionFindById = sinon.stub(); + suggestionFindById.withArgs(SUGGESTION_ID).resolves(makeSuggestion()); + suggestionFindById.withArgs(sid2).resolves(makeSuggestion({ getId: () => sid2 })); + + const ticketCreate = sinon.stub().resolves(ticket1); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: ticketCreate }, + TicketSuggestion: { create: sinon.stub().resolves() }, + Suggestion: { findById: suggestionFindById }, + }, + }); + + const genericErr = new Error('Jira service unavailable'); + const jiraCreateTicket = sinon.stub(); + jiraCreateTicket.onFirstCall().resolves({ + ticketId: 'PROJ-1', ticketKey: 'PROJ-1', ticketUrl: 'https://x.net/PROJ-1', ticketStatus: 'To Do', + }); + jiraCreateTicket.onSecondCall().rejects(genericErr); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns({ createTicket: jiraCreateTicket }) }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Batch generic jira error', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(207); + const body = await res.json(); + const failedItem = body.results.find((r) => r.suggestionId === sid2); + expect(failedItem.status).to.equal(500); + expect(failedItem.error).to.equal('Failed to create ticket'); + }); + + // ── Lines 883-884, 887-888: Ticket.create throws in individual batch → 500 ─── + it('individual batch: records 500 when Ticket.create throws after Jira succeeds', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const conn = makeConnection(); + const ticket1 = makeTicket(); + + const suggestionFindById = sinon.stub(); + suggestionFindById.withArgs(SUGGESTION_ID).resolves(makeSuggestion()); + suggestionFindById.withArgs(sid2).resolves(makeSuggestion({ getId: () => sid2 })); + + const ticketCreate = sinon.stub(); + ticketCreate.onFirstCall().resolves(ticket1); + ticketCreate.onSecondCall().rejects(new Error('DB write failed')); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: ticketCreate }, + TicketSuggestion: { create: sinon.stub().resolves() }, + Suggestion: { findById: suggestionFindById }, + }, + }); + + const jiraCreateTicket = sinon.stub().resolves({ + ticketId: 'PROJ-1', ticketKey: 'PROJ-1', ticketUrl: 'https://x.net/PROJ-1', ticketStatus: 'To Do', + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns({ createTicket: jiraCreateTicket }) }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Batch ticket persist fail', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(207); + const body = await res.json(); + const failedItem = body.results.find((r) => r.suggestionId === sid2); + expect(failedItem.status).to.equal(500); + expect(failedItem.error).to.equal('Ticket created but could not be saved'); + }); + + // ── Lines 918-925: TicketSuggestion.create throws 23505 in batch → 409 ─────── + it('individual batch: records 409 when TicketSuggestion.create hits unique constraint', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const conn = makeConnection(); + const ticket1 = makeTicket(); + const ticket2 = makeTicket({ getId: () => 'ffffffff-aaaa-bbbb-cccc-dddddddddddd' }); + + const suggestionFindById = sinon.stub(); + suggestionFindById.withArgs(SUGGESTION_ID).resolves(makeSuggestion()); + suggestionFindById.withArgs(sid2).resolves(makeSuggestion({ getId: () => sid2 })); + + const ticketCreate = sinon.stub(); + ticketCreate.onFirstCall().resolves(ticket1); + ticketCreate.onSecondCall().resolves(ticket2); + + const dupErr = Object.assign(new Error('duplicate key value violates unique constraint'), { code: '23505' }); + const bridgeCreate = sinon.stub(); + bridgeCreate.onFirstCall().resolves(); + bridgeCreate.onSecondCall().rejects(dupErr); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: ticketCreate }, + TicketSuggestion: { create: bridgeCreate }, + Suggestion: { findById: suggestionFindById }, + }, + }); + + const jiraCreateTicket = sinon.stub().resolves({ + ticketId: 'PROJ-1', ticketKey: 'PROJ-1', ticketUrl: 'https://x.net/PROJ-1', ticketStatus: 'To Do', + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns({ createTicket: jiraCreateTicket }) }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Batch bridge dup', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(207); + const body = await res.json(); + const dupItem = body.results.find((r) => r.suggestionId === sid2); + expect(dupItem.status).to.equal(409); + expect(dupItem.error).to.include('already been ticketed'); + }); + + // ── Lines 918-925 (Case B): TicketSuggestion.create throws generic error in batch → 500 + it('individual batch: records 500 when TicketSuggestion.create throws non-unique error', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const conn = makeConnection(); + const ticket1 = makeTicket(); + const ticket2 = makeTicket({ getId: () => 'ffffffff-aaaa-bbbb-cccc-dddddddddddd' }); + + const suggestionFindById = sinon.stub(); + suggestionFindById.withArgs(SUGGESTION_ID).resolves(makeSuggestion()); + suggestionFindById.withArgs(sid2).resolves(makeSuggestion({ getId: () => sid2 })); + + const ticketCreate = sinon.stub(); + ticketCreate.onFirstCall().resolves(ticket1); + ticketCreate.onSecondCall().resolves(ticket2); + + const bridgeCreate = sinon.stub(); + bridgeCreate.onFirstCall().resolves(); + bridgeCreate.onSecondCall().rejects(new Error('DB connection lost')); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: ticketCreate }, + TicketSuggestion: { create: bridgeCreate }, + Suggestion: { findById: suggestionFindById }, + }, + }); + + const jiraCreateTicket = sinon.stub().resolves({ + ticketId: 'PROJ-1', ticketKey: 'PROJ-1', ticketUrl: 'https://x.net/PROJ-1', ticketStatus: 'To Do', + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { create: sinon.stub().returns({ createTicket: jiraCreateTicket }) }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Batch bridge generic error', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(207); + const body = await res.json(); + const failedItem = body.results.find((r) => r.suggestionId === sid2); + expect(failedItem.status).to.equal(500); + expect(failedItem.error).to.equal('Ticket created but suggestion link could not be saved'); + }); + + // ── Line 937: connection.save() rejects in batch (no success) → still 207 ─── + it('individual batch: logs warn and still returns 207 when connection.save rejects', async () => { + const sid2 = 'dddddddd-eeee-ffff-aaaa-cccccccccccc'; + const conn = makeConnection({ + setLastUsedAt: sinon.stub().returnsThis(), + setErrorMessage: sinon.stub().returnsThis(), + save: sinon.stub().rejects(new Error('save failed')), + }); + + const suggestionFindById = sinon.stub(); + suggestionFindById.withArgs(SUGGESTION_ID).resolves(makeSuggestion()); + suggestionFindById.withArgs(sid2).resolves(null); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: sinon.stub().resolves(makeTicket()) }, + TicketSuggestion: { create: sinon.stub().resolves() }, + Suggestion: { findById: suggestionFindById }, + }, + }); + + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Batch save fail', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID, sid2], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(207); + expect(conn.save).to.have.been.called; + expect(ctx.log.warn).to.have.been.called; + }); + + // ── Lines 967-972: Grouped mode reauth → 409 ───────────────────────────────── + it('grouped mode: returns 409 when ticketClient.createTicket throws 401', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const reauthErr = Object.assign(new Error('Unauthorized'), { status: 401 }); + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ createTicket: sinon.stub().rejects(reauthErr) }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped reauth', + projectKey: 'PROJ', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(409); + const body = await res.json(); + expect(body.message).to.include('expired'); + expect(conn.markRequiresReauth).to.not.have.been.called; + }); + + it('grouped mode: returns 409 and marks reauth when ticketClient.createTicket throws GRANT_REVOKED', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const grantRevokedErr = Object.assign(new Error('Grant revoked'), { code: 'GRANT_REVOKED' }); + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ createTicket: sinon.stub().rejects(grantRevokedErr) }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped grant revoked', + projectKey: 'PROJ', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(409); + const body = await res.json(); + expect(body.message).to.include('reconnect'); + expect(conn.markRequiresReauth).to.have.been.calledOnce; + }); + + // ── Lines 999-1009: Grouped mode Ticket.create throws → 500 ───────────────── + it('grouped mode: returns 500 when Ticket.create throws after Jira createTicket succeeds', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: sinon.stub().rejects(new Error('DB write failed')) }, + TicketSuggestion: { create: sinon.stub().resolves() }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().resolves({ + ticketId: 'PROJ-99', ticketKey: 'PROJ-99', ticketUrl: 'https://x.net/PROJ-99', ticketStatus: 'To Do', + }), + }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped ticket persist fail', + projectKey: 'PROJ', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(500); + const body = await res.json(); + expect(body.message).to.equal('Ticket created but could not be saved'); + }); + + // ── Lines 1046-1048: Grouped bridge non-duplicate error → 201 + linkWarnings ─ + it('grouped mode: returns 201 with linkWarnings when TicketSuggestion.create throws generic error', async () => { + const conn = makeConnection(); + const ticket = makeTicket(); + const genericErr = new Error('DB connection lost'); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: sinon.stub().resolves(ticket) }, + TicketSuggestion: { create: sinon.stub().rejects(genericErr) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const { createTicket } = TaskManagementController(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped generic bridge error', + projectKey: 'PROJ', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(201); + const body = await res.json(); + expect(body.linkWarnings).to.be.an('array').with.lengthOf(1); + expect(body.linkWarnings[0]).to.include('Failed to link suggestion'); + }); + + // ── Lines 1055-1064: Attachment upload fails in grouped mode → 201 + warning ─ + it('grouped mode: returns 201 with attachmentWarning when uploadAttachment throws', async () => { + const conn = makeConnection(); + const ticket = makeTicket(); + const content = Buffer.from('hello world').toString('base64'); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Ticket: { create: sinon.stub().resolves(ticket) }, + TicketSuggestion: { create: sinon.stub().resolves() }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().resolves({ + ticketId: 'PROJ-55', ticketKey: 'PROJ-55', ticketUrl: 'https://x.net/PROJ-55', ticketStatus: 'To Do', + }), + uploadAttachment: sinon.stub().rejects(new Error('S3 upload failed')), + }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Grouped with attachment', + projectKey: 'PROJ', + connectionId: CONN_ID, + mode: 'grouped', + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + attachments: [{ content, mimeType: 'text/plain', filename: 'note.txt' }], + }, + })); + expect(res.status).to.equal(201); + const body = await res.json(); + expect(body).to.have.property('attachmentWarning'); + expect(body.attachmentWarning).to.include('attachment upload failed'); + }); + + // ── Line 1109: Single mode generic Jira error + connection.save rejects → 500 + warn + it('single mode: logs warn and returns 500 when Jira throws generic error and connection.save rejects', async () => { + const conn = makeConnection({ + setErrorMessage: sinon.stub().returnsThis(), + save: sinon.stub().rejects(new Error('save failed')), + }); + + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { findById: sinon.stub().resolves(conn) }, + Suggestion: { findById: sinon.stub().resolves(makeSuggestion()) }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + createTicket: sinon.stub().rejects(new Error('Jira internal error')), + }), + }, + }, + })).default; + + const { createTicket } = Ctrl(ctx); + const res = await createTicket(makeReqCtx({ + data: { + summary: 'Single save fail on error', + projectKey: 'PROJ', + connectionId: CONN_ID, + suggestionIds: [SUGGESTION_ID], + opportunityId: OPPORTUNITY_ID, + }, + })); + expect(res.status).to.equal(500); + expect(conn.save).to.have.been.called; + expect(ctx.log.warn).to.have.been.called; + }); + }); + + // ─── listProjects ──────────────────────────────────────────────────────────── + + describe('listProjects', () => { + function makeReqCtx(overrides = {}) { + return { + params: { organizationId: ORG_ID, connectionId: CONN_ID, ...(overrides.params ?? {}) }, + }; + } + + it('returns 400 for invalid organizationId', async () => { + const { listProjects } = TaskManagementController(makeContext()); + const res = await listProjects(makeReqCtx({ params: { organizationId: 'bad', connectionId: CONN_ID } })); + expect(res.status).to.equal(400); + }); + + it('returns 400 for invalid connectionId', async () => { + const { listProjects } = TaskManagementController(makeContext()); + const res = await listProjects(makeReqCtx({ params: { organizationId: ORG_ID, connectionId: 'not-a-uuid' } })); + expect(res.status).to.equal(400); + }); + + it('returns 404 when organization not found', async () => { + const ctx = makeContext({ dataAccess: { Organization: { findById: sinon.stub().resolves(null) } } }); + const { listProjects } = TaskManagementController(ctx); + const res = await listProjects(makeReqCtx()); + expect(res.status).to.equal(404); + }); + + it('returns 404 when no active connection found', async () => { + const { listProjects } = TaskManagementController(makeContext()); + const res = await listProjects(makeReqCtx()); + expect(res.status).to.equal(404); + }); + + it('returns 409 with connection_reauth_required when connection requires reauth', async () => { + const conn = makeConnection({ getStatus: () => 'requires_reauth' }); + const ctx = makeContext({ + dataAccess: { TaskManagementConnection: { findById: sinon.stub().resolves(conn) } }, + }); + const { listProjects } = TaskManagementController(ctx); + const res = await listProjects(makeReqCtx()); + expect(res.status).to.equal(409); + const body = await res.json(); + expect(body.message).to.equal('connection_reauth_required'); + }); + + it('returns 404 when connection is disabled (non-active, non-reauth status)', async () => { + const conn = makeConnection({ getStatus: () => 'disabled' }); + const ctx = makeContext({ + dataAccess: { TaskManagementConnection: { findById: sinon.stub().resolves(conn) } }, + }); + const { listProjects } = TaskManagementController(ctx); + const res = await listProjects(makeReqCtx()); + expect(res.status).to.equal(404); + }); + + it('returns 500 on connection load error', async () => { + const ctx = makeContext(); + ctx.dataAccess.TaskManagementConnection.findById.rejects(new Error('db')); + const { listProjects } = TaskManagementController(ctx); + const res = await listProjects(makeReqCtx()); + expect(res.status).to.equal(500); + }); + + it('returns 200 with project list', async () => { + const conn = makeConnection(); + const projects = [{ key: 'PROJ', name: 'My Project' }]; + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + listProjects: sinon.stub().resolves(projects), + }), + }, + }, + })).default; + + const { listProjects } = Ctrl(ctx); + const res = await listProjects(makeReqCtx()); + expect(res.status).to.equal(200); + const body = await res.json(); + expect(body.projects).to.deep.equal(projects); + }); + + it('returns 409 and does not mark reauth when Jira client returns 401', async () => { + const conn = makeConnection(); + const err = Object.assign(new Error('Unauthorized'), { status: 401 }); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ listProjects: sinon.stub().rejects(err) }), + }, + }, + })).default; + + const { listProjects } = Ctrl(ctx); + const res = await listProjects(makeReqCtx()); + expect(res.status).to.equal(409); + expect(conn.markRequiresReauth).to.not.have.been.called; + }); + + it('returns 409 and marks reauth when Jira client throws GRANT_REVOKED', async () => { + const conn = makeConnection(); + const err = Object.assign(new Error('Grant revoked'), { code: 'GRANT_REVOKED' }); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ listProjects: sinon.stub().rejects(err) }), + }, + }, + })).default; + + const { listProjects } = Ctrl(ctx); + const res = await listProjects(makeReqCtx()); + expect(res.status).to.equal(409); + expect(conn.markRequiresReauth).to.have.been.calledOnce; + }); + + it('returns 500 on generic list projects error', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ listProjects: sinon.stub().rejects(new Error('timeout')) }), + }, + }, + })).default; + + const { listProjects } = Ctrl(ctx); + const res = await listProjects(makeReqCtx()); + expect(res.status).to.equal(500); + }); + }); + + describe('listIssueTypes', () => { + const PROJECT_ID = '10001'; + + function makeReqCtx(overrides = {}) { + const qs = 'queryStringParameters' in overrides + ? overrides.queryStringParameters + : { projectId: PROJECT_ID }; + const url = new URL('http://localhost/'); + for (const [k, v] of Object.entries(qs)) { + url.searchParams.set(k, v); + } + return { + params: { organizationId: ORG_ID, connectionId: CONN_ID, ...(overrides.params ?? {}) }, + request: { url: url.toString() }, + }; + } + + it('returns 400 for invalid organizationId', async () => { + const { listIssueTypes } = TaskManagementController(makeContext()); + const res = await listIssueTypes(makeReqCtx({ params: { organizationId: 'bad', connectionId: CONN_ID } })); + expect(res.status).to.equal(400); + }); + + it('returns 400 for invalid connectionId', async () => { + const { listIssueTypes } = TaskManagementController(makeContext()); + const res = await listIssueTypes(makeReqCtx({ params: { organizationId: ORG_ID, connectionId: 'not-a-uuid' } })); + expect(res.status).to.equal(400); + }); + + it('returns 400 when projectId is missing', async () => { + const { listIssueTypes } = TaskManagementController(makeContext()); + const res = await listIssueTypes(makeReqCtx({ queryStringParameters: {} })); + expect(res.status).to.equal(400); + }); + + it('returns 400 when projectId is not numeric', async () => { + const { listIssueTypes } = TaskManagementController(makeContext()); + const res = await listIssueTypes(makeReqCtx({ queryStringParameters: { projectId: 'PROJ' } })); + expect(res.status).to.equal(400); + const body = await res.json(); + expect(body.message).to.include('numeric'); + }); + + it('returns 404 when organization not found', async () => { + const ctx = makeContext({ dataAccess: { Organization: { findById: sinon.stub().resolves(null) } } }); + const { listIssueTypes } = TaskManagementController(ctx); + const res = await listIssueTypes(makeReqCtx()); + expect(res.status).to.equal(404); + }); + + it('returns 404 when no active connection found', async () => { + const { listIssueTypes } = TaskManagementController(makeContext()); + const res = await listIssueTypes(makeReqCtx()); + expect(res.status).to.equal(404); + }); + + it('returns 409 with connection_reauth_required when connection requires reauth', async () => { + const conn = makeConnection({ getStatus: () => 'requires_reauth' }); + const ctx = makeContext({ + dataAccess: { TaskManagementConnection: { findById: sinon.stub().resolves(conn) } }, + }); + const { listIssueTypes } = TaskManagementController(ctx); + const res = await listIssueTypes(makeReqCtx()); + expect(res.status).to.equal(409); + const body = await res.json(); + expect(body.message).to.equal('connection_reauth_required'); + }); + + it('returns 404 when connection is disabled (non-active, non-reauth status)', async () => { + const conn = makeConnection({ getStatus: () => 'disabled' }); + const ctx = makeContext({ + dataAccess: { TaskManagementConnection: { findById: sinon.stub().resolves(conn) } }, + }); + const { listIssueTypes } = TaskManagementController(ctx); + const res = await listIssueTypes(makeReqCtx()); + expect(res.status).to.equal(404); + }); + + it('returns 500 on connection load error', async () => { + const ctx = makeContext(); + ctx.dataAccess.TaskManagementConnection.findById.rejects(new Error('db')); + const { listIssueTypes } = TaskManagementController(ctx); + const res = await listIssueTypes(makeReqCtx()); + expect(res.status).to.equal(500); + }); + + it('returns 200 with issue type list', async () => { + const conn = makeConnection(); + const issueTypes = [{ id: '10001', name: 'Story' }, { id: '10002', name: 'Bug' }]; + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ + listIssueTypes: sinon.stub().resolves(issueTypes), + }), + }, + }, + })).default; + + const { listIssueTypes } = Ctrl(ctx); + const res = await listIssueTypes(makeReqCtx()); + expect(res.status).to.equal(200); + const body = await res.json(); + expect(body.issueTypes).to.deep.equal(issueTypes); + }); + + it('returns 409 and does not mark reauth when Jira client returns 401', async () => { + const conn = makeConnection(); + const err = Object.assign(new Error('Unauthorized'), { status: 401 }); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ listIssueTypes: sinon.stub().rejects(err) }), + }, + }, + })).default; + + const { listIssueTypes } = Ctrl(ctx); + const res = await listIssueTypes(makeReqCtx()); + expect(res.status).to.equal(409); + expect(conn.markRequiresReauth).to.not.have.been.called; + }); + + it('returns 409 and marks reauth when Jira client throws GRANT_REVOKED', async () => { + const conn = makeConnection(); + const err = Object.assign(new Error('Grant revoked'), { code: 'GRANT_REVOKED' }); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ listIssueTypes: sinon.stub().rejects(err) }), + }, + }, + })).default; + + const { listIssueTypes } = Ctrl(ctx); + const res = await listIssueTypes(makeReqCtx()); + expect(res.status).to.equal(409); + expect(conn.markRequiresReauth).to.have.been.calledOnce; + }); + + it('returns 500 on generic list issue types error', async () => { + const conn = makeConnection(); + const ctx = makeContext({ + dataAccess: { + TaskManagementConnection: { + findById: sinon.stub().resolves(conn), + }, + }, + }); + + const Ctrl = (await esmock('../../src/controllers/task-management.js', { + '@aws-sdk/client-secrets-manager': { + SecretsManagerClient: class { + // eslint-disable-next-line class-methods-use-this + send() { return Promise.resolve({}); } + }, + }, + '@adobe/spacecat-shared-ticket-client': { + TicketClientFactory: { + create: sinon.stub().returns({ listIssueTypes: sinon.stub().rejects(new Error('timeout')) }), + }, + }, + })).default; + + const { listIssueTypes } = Ctrl(ctx); + const res = await listIssueTypes(makeReqCtx()); + expect(res.status).to.equal(500); + }); + }); +}); diff --git a/test/index.test.js b/test/index.test.js index f42df7bc77..2a4129e56d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -177,7 +177,11 @@ describe('Index Tests', () => { findByHashedApiKey: sinon.stub().resolves(null), }, Opportunity: {}, - Suggestion: {}, + Suggestion: { findById: sinon.stub() }, + TaskManagementConnection: { allByOrganizationId: sinon.stub() }, + Ticket: { findById: sinon.stub() }, + TicketSuggestion: { findBySuggestionId: sinon.stub() }, + IdempotencyKey: { findActiveKey: sinon.stub(), create: sinon.stub() }, }, s3Client: { send: sinon.stub(), @@ -414,6 +418,20 @@ describe('Index Tests', () => { expect(resp.headers.plain()['x-error']).to.equal('Job Id is invalid. Please provide a valid UUID.'); }); + it('rejects task-management connection route with invalid connectionId', async () => { + const orgId = 'e730ec12-4325-4bdd-ac71-0f4aa5b18cff'; + context.pathInfo.suffix = `/organizations/${orgId}/task-management/connections/not-a-uuid`; + + request = new Request(`${baseUrl}/organizations/${orgId}/task-management/connections/not-a-uuid`, { + headers: { 'x-api-key': apiKey }, + }); + + const resp = await main(request, context); + + expect(resp.status).to.equal(400); + expect(resp.headers.plain()['x-error']).to.equal('Connection Id is invalid. Please provide a valid UUID.'); + }); + it('handles dynamic route errors', async () => { context.pathInfo.suffix = '/sites/e730ec12-4325-4bdd-ac71-0f4aa5b18cff'; diff --git a/test/it/postgres/seed-data/task-management-connections.js b/test/it/postgres/seed-data/task-management-connections.js new file mode 100644 index 0000000000..088c52aea1 --- /dev/null +++ b/test/it/postgres/seed-data/task-management-connections.js @@ -0,0 +1,53 @@ +/* + * 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. + */ + +/** + * Baseline task-management connections for IT tests. + * + * - CONN_1: ORG_1, jira_cloud, active — primary connection for ticket creation tests + * - CONN_2: ORG_2, jira_cloud, active — used to verify cross-org isolation + */ +import { + CONN_1_ID, + CONN_2_ID, + ORG_1_ID, + ORG_2_ID, +} from '../../shared/seed-ids.js'; + +export const taskManagementConnections = [ + { + id: CONN_1_ID, + organization_id: ORG_1_ID, + provider: 'jira_cloud', + status: 'active', + display_name: 'Org1 Jira', + instance_url: 'https://org1.atlassian.net', + connected_by: 'test-user-sub', + external_instance_id: 'aabbccdd-0001-4000-b000-000000000001', + metadata: { cloudId: 'aabbccdd-0001-4000-b000-000000000001', scopes: ['read:jira-work', 'write:jira-work'] }, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }, + { + id: CONN_2_ID, + organization_id: ORG_2_ID, + provider: 'jira_cloud', + status: 'active', + display_name: 'Org2 Jira', + instance_url: 'https://org2.atlassian.net', + connected_by: 'test-user-sub', + external_instance_id: 'aabbccdd-0002-4000-b000-000000000002', + metadata: { cloudId: 'aabbccdd-0002-4000-b000-000000000002', scopes: ['read:jira-work', 'write:jira-work'] }, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }, +]; diff --git a/test/it/postgres/seed-data/ticket-suggestions.js b/test/it/postgres/seed-data/ticket-suggestions.js new file mode 100644 index 0000000000..2ea7346758 --- /dev/null +++ b/test/it/postgres/seed-data/ticket-suggestions.js @@ -0,0 +1,36 @@ +/* + * 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. + */ + +/** + * Baseline ticket-suggestion bridge rows for IT tests. + * + * - TICKET_SUGG_1: links TICKET_1 ↔ TASK_MGMT_SUGGESTION_ID (logical DynamoDB ID, no FK) + * + * suggestion_id is a TEXT column (not a FK) — suggestions live in DynamoDB. + */ +import { + TICKET_SUGG_1_ID, + TICKET_1_ID, + OPPTY_1_ID, + TASK_MGMT_SUGGESTION_ID, +} from '../../shared/seed-ids.js'; + +export const ticketSuggestions = [ + { + id: TICKET_SUGG_1_ID, + ticket_id: TICKET_1_ID, + suggestion_id: TASK_MGMT_SUGGESTION_ID, + opportunity_id: OPPTY_1_ID, + created_by: 'test-user-sub', + created_at: '2026-01-01T00:00:00.000Z', + }, +]; diff --git a/test/it/postgres/seed-data/tickets.js b/test/it/postgres/seed-data/tickets.js new file mode 100644 index 0000000000..e24bd0dba9 --- /dev/null +++ b/test/it/postgres/seed-data/tickets.js @@ -0,0 +1,56 @@ +/* + * 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. + */ + +/** + * Baseline tickets for IT tests. + * + * - TICKET_1: ORG_1, linked to OPPTY_1, will have a TicketSuggestion bridge row + * - TICKET_2: ORG_1, linked to OPPTY_1, no suggestion link + */ +import { + TICKET_1_ID, + TICKET_2_ID, + CONN_1_ID, + ORG_1_ID, + OPPTY_1_ID, +} from '../../shared/seed-ids.js'; + +export const tickets = [ + { + id: TICKET_1_ID, + organization_id: ORG_1_ID, + connection_id: CONN_1_ID, + opportunity_id: OPPTY_1_ID, + external_ticket_id: '10001', + ticket_key: 'TEST-1', + ticket_url: 'https://org1.atlassian.net/browse/TEST-1', + ticket_status: 'Open', + ticket_provider: 'jira_cloud', + created_by: 'test-user-sub', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }, + { + id: TICKET_2_ID, + organization_id: ORG_1_ID, + connection_id: CONN_1_ID, + opportunity_id: OPPTY_1_ID, + external_ticket_id: '10002', + ticket_key: 'TEST-2', + ticket_url: 'https://org1.atlassian.net/browse/TEST-2', + ticket_status: 'Open', + ticket_provider: 'jira_cloud', + created_by: 'test-user-sub', + created_at: '2026-01-02T00:00:00.000Z', + updated_at: '2026-01-02T00:00:00.000Z', + }, +]; diff --git a/test/it/postgres/seed.js b/test/it/postgres/seed.js index de840c1f78..c82291a495 100644 --- a/test/it/postgres/seed.js +++ b/test/it/postgres/seed.js @@ -42,6 +42,9 @@ import { projectionAudits } from './seed-data/projection-audits.js'; import { featureFlags } from './seed-data/feature-flags.js'; import { prompts } from './seed-data/prompts.js'; import { brandPresenceExecutions } from './seed-data/brand-presence-executions.js'; +import { taskManagementConnections } from './seed-data/task-management-connections.js'; +import { tickets } from './seed-data/tickets.js'; +import { ticketSuggestions } from './seed-data/ticket-suggestions.js'; const POSTGREST_PORT = process.env.IT_POSTGREST_PORT || '3300'; const POSTGREST_URL = `http://localhost:${POSTGREST_PORT}`; @@ -137,6 +140,7 @@ async function seed() { // feature_flags grants INSERT to postgrest_writer only (SELECT to anon), so // seed it with the writer JWT — same as the append-only audit tables. insertRows('feature_flags', featureFlags, { asWriter: true }), + insertRows('task_management_connections', taskManagementConnections), ]); // Level 1b: depend on projects @@ -165,10 +169,14 @@ async function seed() { insertRows('audit_urls', auditUrls), insertRows('sentiment_guidelines', sentimentGuidelines), insertRows('brand_sites', brandSites), + insertRows('tickets', tickets), ]); - // Level 4: depend on fix_entities + suggestions - await insertRows('fix_entity_suggestions', fixEntitySuggestions); + // Level 4: depend on fix_entities + suggestions + tickets + await Promise.all([ + insertRows('fix_entity_suggestions', fixEntitySuggestions), + insertRows('ticket_suggestions', ticketSuggestions), + ]); } /** diff --git a/test/it/postgres/task-management.test.js b/test/it/postgres/task-management.test.js new file mode 100644 index 0000000000..a644152361 --- /dev/null +++ b/test/it/postgres/task-management.test.js @@ -0,0 +1,17 @@ +/* + * 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 { ctx } from './harness.js'; +import { resetPostgres } from './seed.js'; +import taskManagementTests from '../shared/tests/task-management.js'; + +taskManagementTests(() => ctx.httpClient, resetPostgres); diff --git a/test/it/shared/seed-ids.js b/test/it/shared/seed-ids.js index 017c758859..baf8a103be 100644 --- a/test/it/shared/seed-ids.js +++ b/test/it/shared/seed-ids.js @@ -271,3 +271,15 @@ export const NON_EXISTENT_PROJECT_ID = 'ff999999-9999-4999-a999-999999999999'; export const NON_EXISTENT_TOPIC_ID = 'a9999999-9999-4999-b999-999999999999'; export const NON_EXISTENT_GUIDELINE_ID = 'b9999999-9999-4999-b999-999999999999'; export const NON_EXISTENT_CONSUMER_ID = '99999999-9999-4999-b999-999999999998'; + +// ── Task Management ── + +export const CONN_1_ID = 'ac111111-1111-4111-b111-111111111111'; // ORG_1, jira_cloud, active +export const CONN_2_ID = 'ac222222-2222-4222-a222-222222222222'; // ORG_2, jira_cloud, active +export const TICKET_1_ID = 'ad111111-1111-4111-b111-111111111111'; // ORG_1, linked to TASK_MGMT_SUGGESTION_ID +export const TICKET_2_ID = 'ad222222-2222-4222-a222-222222222222'; // ORG_1, no suggestion link +export const TICKET_SUGG_1_ID = 'ae111111-1111-4111-b111-111111111111'; // bridge: TICKET_1 ↔ TASK_MGMT_SUGGESTION_ID +export const NON_EXISTENT_CONN_ID = 'ac999999-9999-4999-b999-999999999999'; +export const NON_EXISTENT_TICKET_ID = 'ad999999-9999-4999-b999-999999999999'; +// Logical suggestion ID — no FK (suggestions live in DynamoDB) +export const TASK_MGMT_SUGGESTION_ID = 'af111111-1111-4111-b111-111111111111'; diff --git a/test/it/shared/tests/task-management.js b/test/it/shared/tests/task-management.js new file mode 100644 index 0000000000..277a10cb33 --- /dev/null +++ b/test/it/shared/tests/task-management.js @@ -0,0 +1,361 @@ +/* + * 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 { + ORG_1_ID, + ORG_2_ID, + NON_EXISTENT_ORG_ID, + CONN_1_ID, + CONN_2_ID, + NON_EXISTENT_CONN_ID, + TICKET_1_ID, + TICKET_2_ID, + NON_EXISTENT_TICKET_ID, + TASK_MGMT_SUGGESTION_ID, + OPPTY_1_ID, +} from '../seed-ids.js'; + +function expectConnectionDto(conn) { + expect(conn).to.be.an('object'); + expect(conn.id).to.be.a('string'); + expect(conn.organizationId).to.be.a('string'); + expect(conn.provider).to.be.a('string'); + expect(conn.status).to.be.a('string'); + expect(conn.displayName).to.be.a('string'); + expect(conn.instanceUrl).to.be.a('string'); +} + +function expectTicketDto(ticket) { + expect(ticket).to.be.an('object'); + expect(ticket.id).to.be.a('string'); + expect(ticket.organizationId).to.be.a('string'); + expect(ticket.ticketKey).to.be.a('string'); + expect(ticket.ticketUrl).to.be.a('string'); + expect(ticket.ticketProvider).to.be.a('string'); +} + +/** + * Shared task-management endpoint tests. + * + * Covers: + * - GET /organizations/:orgId/task-management/connections + * - GET /organizations/:orgId/task-management/connections/:connId + * - GET /organizations/:orgId/task-management/tickets + * - GET /organizations/:orgId/suggestions/:suggestionId/ticket + * - GET /organizations/:orgId/opportunities/:opportunityId/tickets + * - POST /organizations/:orgId/task-management/:provider/tickets (validation paths only) + * - GET /organizations/:orgId/task-management/connections/:connId/projects (validation only) + * - GET /organizations/:orgId/task-management/connections/:connId/issue-types (validation only) + * + * @param {() => object} getHttpClient + * @param {() => Promise} resetData + */ +export default function taskManagementTests(getHttpClient, resetData) { + describe('Task Management', () => { + // ── GET /organizations/:orgId/task-management/connections ───────────────── + + describe('GET /organizations/:orgId/task-management/connections', () => { + before(() => resetData()); + + it('admin: lists connections for org', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections`); + expect(res.status).to.equal(200); + expect(res.body).to.be.an('array').with.lengthOf(1); + expectConnectionDto(res.body[0]); + expect(res.body[0].id).to.equal(CONN_1_ID); + }); + + it('user: returns 403 for org the user does not belong to', async () => { + const http = getHttpClient(); + const res = await http.user.get(`/organizations/${ORG_2_ID}/task-management/connections`); + expect(res.status).to.equal(403); + }); + + it('admin: returns 404 for non-existent org', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${NON_EXISTENT_ORG_ID}/task-management/connections`); + expect(res.status).to.equal(404); + }); + + it('returns 400 for invalid organizationId', async () => { + const http = getHttpClient(); + const res = await http.admin.get('/organizations/not-a-uuid/task-management/connections'); + expect(res.status).to.equal(400); + }); + + it('admin: filters connections by provider query param', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections?provider=jira_cloud`); + expect(res.status).to.equal(200); + expect(res.body).to.be.an('array').with.lengthOf(1); + expect(res.body[0].provider).to.equal('jira_cloud'); + }); + + it('admin: returns empty array for unknown provider filter', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections?provider=unknown`); + expect(res.status).to.equal(200); + expect(res.body).to.be.an('array').with.lengthOf(0); + }); + }); + + // ── GET /organizations/:orgId/task-management/connections/:connId ───────── + + describe('GET /organizations/:orgId/task-management/connections/:connId', () => { + before(() => resetData()); + + it('admin: returns connection by id', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections/${CONN_1_ID}`); + expect(res.status).to.equal(200); + expectConnectionDto(res.body); + expect(res.body.id).to.equal(CONN_1_ID); + expect(res.body.organizationId).to.equal(ORG_1_ID); + }); + + it('returns 404 when connection belongs to a different org', async () => { + const http = getHttpClient(); + // CONN_2 belongs to ORG_2 — requesting it under ORG_1 must 404 + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections/${CONN_2_ID}`); + expect(res.status).to.equal(404); + }); + + it('returns 404 for non-existent connection', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections/${NON_EXISTENT_CONN_ID}`); + expect(res.status).to.equal(404); + }); + }); + + // ── GET /organizations/:orgId/task-management/tickets ──────────────────── + + describe('GET /organizations/:orgId/task-management/tickets', () => { + before(() => resetData()); + + it('admin: lists tickets for org', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/tickets`); + expect(res.status).to.equal(200); + expect(res.body).to.be.an('array').with.lengthOf(2); + res.body.forEach((t) => expectTicketDto(t)); + const ids = res.body.map((t) => t.id); + expect(ids).to.include(TICKET_1_ID); + expect(ids).to.include(TICKET_2_ID); + }); + + it('admin: ORG_2 returns empty array (no tickets seeded for ORG_2)', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_2_ID}/task-management/tickets`); + expect(res.status).to.equal(200); + expect(res.body).to.be.an('array').with.lengthOf(0); + }); + + it('user: returns 403 for org the user does not belong to', async () => { + const http = getHttpClient(); + const res = await http.user.get(`/organizations/${ORG_2_ID}/task-management/tickets`); + expect(res.status).to.equal(403); + }); + + it('TICKET_1 response includes its suggestion bridge', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/tickets`); + expect(res.status).to.equal(200); + const ticket1 = res.body.find((t) => t.id === TICKET_1_ID); + expect(ticket1).to.exist; + expect(ticket1.suggestions).to.be.an('array').that.includes(TASK_MGMT_SUGGESTION_ID); + }); + }); + + // ── GET /organizations/:orgId/suggestions/:suggestionId/ticket ──────────── + + describe('GET /organizations/:orgId/suggestions/:suggestionId/ticket', () => { + before(() => resetData()); + + it('admin: returns ticket for a linked suggestion', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/suggestions/${TASK_MGMT_SUGGESTION_ID}/ticket`); + expect(res.status).to.equal(200); + expectTicketDto(res.body); + expect(res.body.id).to.equal(TICKET_1_ID); + }); + + it('returns 404 for a suggestion with no ticket', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/suggestions/${NON_EXISTENT_TICKET_ID}/ticket`); + expect(res.status).to.equal(404); + }); + }); + + // ── GET /organizations/:orgId/opportunities/:opportunityId/tickets ───────── + + describe('GET /organizations/:orgId/opportunities/:opportunityId/tickets', () => { + before(() => resetData()); + + it('admin: returns all tickets for an opportunity', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/opportunities/${OPPTY_1_ID}/tickets`); + expect(res.status).to.equal(200); + expect(res.body).to.be.an('array').with.lengthOf(2); + const ids = res.body.map((t) => t.id); + expect(ids).to.include(TICKET_1_ID); + expect(ids).to.include(TICKET_2_ID); + }); + + it('admin: ORG_2 gets empty array for OPPTY_1 (cross-org filter)', async () => { + // OPPTY_1's tickets belong to ORG_1; querying via ORG_2 must return [] + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_2_ID}/opportunities/${OPPTY_1_ID}/tickets`); + expect(res.status).to.equal(200); + expect(res.body).to.be.an('array').with.lengthOf(0); + }); + + it('returns empty array when opportunity has no tickets', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/opportunities/${NON_EXISTENT_TICKET_ID}/tickets`); + expect(res.status).to.equal(200); + expect(res.body).to.be.an('array').with.lengthOf(0); + }); + }); + + // ── POST /organizations/:orgId/task-management/:provider/tickets ────────── + // Validation paths only — no live Jira call is made for these cases. + + describe('POST /organizations/:orgId/task-management/:provider/tickets (validation)', () => { + before(() => resetData()); + + it('returns 400 when summary is missing', async () => { + const http = getHttpClient(); + const res = await http.admin.post( + `/organizations/${ORG_1_ID}/task-management/jira_cloud/tickets`, + { projectKey: 'PROJ', connectionId: CONN_1_ID }, + ); + expect(res.status).to.equal(400); + }); + + it('returns 400 when projectKey is missing', async () => { + const http = getHttpClient(); + const res = await http.admin.post( + `/organizations/${ORG_1_ID}/task-management/jira_cloud/tickets`, + { summary: 'Fix issue', connectionId: CONN_1_ID }, + ); + expect(res.status).to.equal(400); + }); + + it('returns 400 when connectionId is missing', async () => { + const http = getHttpClient(); + const res = await http.admin.post( + `/organizations/${ORG_1_ID}/task-management/jira_cloud/tickets`, + { summary: 'Fix issue', projectKey: 'PROJ' }, + ); + expect(res.status).to.equal(400); + }); + + it('returns 400 for unsupported provider', async () => { + const http = getHttpClient(); + const res = await http.admin.post( + `/organizations/${ORG_1_ID}/task-management/unknown_provider/tickets`, + { summary: 'Fix issue', projectKey: 'PROJ', connectionId: CONN_1_ID }, + ); + expect(res.status).to.equal(400); + }); + + it('returns 404 when connection does not exist', async () => { + const http = getHttpClient(); + const res = await http.admin.post( + `/organizations/${ORG_1_ID}/task-management/jira_cloud/tickets`, + { summary: 'Fix issue', projectKey: 'PROJ', connectionId: NON_EXISTENT_CONN_ID }, + ); + expect(res.status).to.equal(404); + }); + + it('returns 403 when user does not have access to org', async () => { + const http = getHttpClient(); + const res = await http.user.post( + `/organizations/${ORG_2_ID}/task-management/jira_cloud/tickets`, + { summary: 'Fix issue', projectKey: 'PROJ', connectionId: CONN_2_ID }, + ); + expect(res.status).to.equal(403); + }); + + it('returns 400 for invalid attachment mimeType', async () => { + const http = getHttpClient(); + const res = await http.admin.post( + `/organizations/${ORG_1_ID}/task-management/jira_cloud/tickets`, + { + summary: 'Fix issue', + projectKey: 'PROJ', + connectionId: CONN_1_ID, + attachments: [{ + content: Buffer.from('x').toString('base64'), + mimeType: 'application/javascript', + filename: 'evil.js', + }], + }, + ); + expect(res.status).to.equal(400); + expect(res.body.message).to.include('mimeType'); + }); + }); + + // ── GET /organizations/:orgId/task-management/connections/:connId/projects ─ + // Validation + auth paths only — no live Jira call for these cases. + + describe('GET /connections/:connId/projects (validation)', () => { + before(() => resetData()); + + it('returns 400 for invalid connectionId', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections/not-a-uuid/projects`); + expect(res.status).to.equal(400); + }); + + it('returns 404 for non-existent connection', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections/${NON_EXISTENT_CONN_ID}/projects`); + expect(res.status).to.equal(404); + }); + + it('returns 404 when connection belongs to a different org', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections/${CONN_2_ID}/projects`); + expect(res.status).to.equal(404); + }); + }); + + // ── GET /organizations/:orgId/task-management/connections/:connId/issue-types + // Validation paths only. + + describe('GET /connections/:connId/issue-types (validation)', () => { + before(() => resetData()); + + it('returns 400 when projectId is missing', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections/${CONN_1_ID}/issue-types`); + expect(res.status).to.equal(400); + }); + + it('returns 400 when projectId is not numeric', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections/${CONN_1_ID}/issue-types?projectId=PROJ`); + expect(res.status).to.equal(400); + }); + + it('returns 404 for non-existent connection', async () => { + const http = getHttpClient(); + const res = await http.admin.get(`/organizations/${ORG_1_ID}/task-management/connections/${NON_EXISTENT_CONN_ID}/issue-types?projectId=10001`); + expect(res.status).to.equal(404); + }); + }); + }); +} diff --git a/test/routes/index.test.js b/test/routes/index.test.js index f3e97fee6e..8f4a9c919b 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -602,6 +602,17 @@ describe('getRouteHandlers', () => { getPreview: sinon.stub(), }; + const mockTaskManagementController = { + listConnections: sinon.stub(), + getConnection: sinon.stub(), + listTickets: sinon.stub(), + getTicketBySuggestion: sinon.stub(), + listTicketsByOpportunity: sinon.stub(), + createTicket: sinon.stub(), + listProjects: sinon.stub(), + listIssueTypes: sinon.stub(), + }; + const mockRedirectsController = { getRedirects: sinon.stub(), }; @@ -671,6 +682,7 @@ describe('getRouteHandlers', () => { mockSerenityController, mockElementsController, mockProxyController, + mockTaskManagementController, mockRedirectsController, ); @@ -1288,6 +1300,14 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/agentic-page-types', 'PATCH /sites/:siteId/agentic-page-types/:name', 'DELETE /sites/:siteId/agentic-page-types/:name', + 'GET /organizations/:organizationId/task-management/connections', + 'GET /organizations/:organizationId/task-management/connections/:connectionId', + 'GET /organizations/:organizationId/task-management/tickets', + 'GET /organizations/:organizationId/suggestions/:suggestionId/ticket', + 'GET /organizations/:organizationId/opportunities/:opportunityId/tickets', + 'POST /organizations/:organizationId/task-management/:provider/tickets', + 'GET /organizations/:organizationId/task-management/connections/:connectionId/projects', + 'GET /organizations/:organizationId/task-management/connections/:connectionId/issue-types', ]; expect(Object.keys(dynamicRoutes)).to.have.members(expectedDynamicRouteKeys);