diff --git a/README.md b/README.md index bae3af1..e20cc53 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Extensao Chrome Manifest V3 que injeta o Arch Panel em paginas Oracle usadas pel - `src/content/manage-time-controller.js`: controller da modal Manage Time, incluindo carregamento, diff, foco, validacao e gravacao. - `src/content/export-html.js`: geracao do relatorio HTML standalone baixado pelo usuario. - `src/repositories/workbench-repository.js`: acesso e normalizacao de dados do Workload Workbench e Worklist user API. +- `src/repositories/crm-repository.js`: busca paginada de Service Requests no CRM Fusion para cobertura por oportunidade (OptyNumber). - `src/repositories/comcip-repository.js`: acesso e normalizacao de dados COMCIP, Manage Time e Service Requests. - `src/repositories/cache-repository.js`: persistencia dos payloads de dashboard, task types e token Worklist no IndexedDB. - `src/domain/ramp-forecast.js`: regras puras de calculo de ramp, delta de forecast e payload de atualizacao de forecast. @@ -58,6 +59,7 @@ src/ repositories/ cache-repository.js comcip-repository.js + crm-repository.js workbench-repository.js views/ dashboard-view.js @@ -93,6 +95,26 @@ Requests, cache especifico da extensao e normalizadores de payload de APIs exter ## Fluxos principais - Workload dashboard: carrega usuario, customers, workloads, actions e service requests. +- Cobertura de SRs: faz POST em + `https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries` + com `CRMServiceRequest`, `ORA_SVC_MY_SR_TEAM_SRS` e `SVC_PENDING_DELIVERY`. + Percorre todas as paginas de 15 itens enquanto `hasMore` for true; o offset + avanca pelo numero de itens retornados. O dashboard cruza + `OpportunityName_Id_c.OptyNumber` com `workload.opportunityNumber` (sem diferenciar + maiusculas/minusculas e ignorando espacos externos). Workloads da mesma + oportunidade compartilham o indicador `hasSR`; SRs sem oportunidade nao geram + correspondencias. Nao ha consulta nem fallback para SRs por workload. +- Autenticacao CRM: a page bridge roda desde `document_start` nas telas HCM/FSCM, + captura headers de fetch/XHR de `/crmRestApi/` na origem Fusion e os reaproveita + nas paginas seguintes, junto aos cookies da sessao (`credentials: include`). + Se a chamada direta retornar 401/403, a mesma pagina e repetida pelo proxy + COMCIP ja configurado para esse endpoint `queries`, com o payload inalterado. + Todas as paginas restantes da extracao usam esse proxy. O background reutiliza + a sessao COMCIP, `authorization: Session`, `x-appbuilder-client-id` obtido pelo + probe e os cookies da origem; nao precisa capturar previamente uma chamada CRM + na tela FuseWelcome. Falhas no proxy interrompem a extracao sem salvar dados + parciais. Os headers CRM ficam separados dos tokens do provider SPA. A consulta + existente de SRs/horas no COMCIP permanece independente da cobertura por oportunidade. - HCM tile: adiciona o tile `Arch Panel` dentro de `yourapps_groupNode_my_information` em `FuseWelcome`. - Worklist token: captura o token do iframe Worklist SaasUI e persiste em IndexedDB para resolver o usuario atual. - SPA Workbench iframe: cria um iframe oculto do Workload Workbench para obter contexto autenticado e executar requests da origem `spa.oracle.com`. @@ -107,7 +129,9 @@ Store: `datasets` Chaves conhecidas: -- `workload-dashboard`: ultimo dataset do dashboard. +- `workload-dashboard-opportunity-srs`: ultimo dataset com cobertura por oportunidade. + Snapshots antigos em `workload-dashboard` nao sao usados pela nova estrategia; + o usuario deve executar Refresh data para gerar o novo snapshot. - `time-management-task-types`: cache de task types atualizado apenas em refresh data. - `worklist-saasui-token`: token atual extraido do iframe Worklist. @@ -120,6 +144,13 @@ Chaves conhecidas: ## Validacao local +Regressoes de paginacao, associacao por OptyNumber, fluxo completo do dashboard, +cache e headers/cookies da page bridge (dados simulados; sem chamadas Oracle): + +```powershell +node --test tests/opportunity-service-requests.test.js +``` + ```powershell node --check content-script.js node --check background.js @@ -133,6 +164,7 @@ node --check src/content/shell-view.js node --check src/content/templates.js node --check src/content/workbench-controller.js node --check src/repositories/workbench-repository.js +node --check src/repositories/crm-repository.js node --check src/repositories/comcip-repository.js node --check src/repositories/cache-repository.js node --check src/domain/ramp-forecast.js @@ -156,14 +188,10 @@ node --check src/services/transport-service.js A versao oficial da extensao fica em `manifest.json`. -Toda alteracao funcional, visual ou tecnica na extensao deve incrementar a terceira casa da versao antes da entrega: - -```powershell -powershell -ExecutionPolicy Bypass -File tools/bump-extension-version.ps1 -``` - +Nao incrementar a versao nem criar commits automaticamente a cada pedido de mudanca. +O fechamento de versao deve ocorrer apenas quando solicitado explicitamente pelo usuario. Quando uma versao especifica for solicitada, informe o valor explicitamente: ```powershell -powershell -ExecutionPolicy Bypass -File tools/bump-extension-version.ps1 -Version 1.0.25 +powershell -ExecutionPolicy Bypass -File tools/bump-extension-version.ps1 -Version 1.0.11 ``` diff --git a/content-script.js b/content-script.js index 1aaed62..9ac091c 100644 --- a/content-script.js +++ b/content-script.js @@ -96,7 +96,7 @@ normalizeWorkload, isWonWorkload, normalizeTeamValue, - teamContainsUser, + indexServiceRequestsByOpportunity, stripTransientFields, createExportPayload, createDatasetSnapshot, @@ -657,6 +657,16 @@ normalizeTeamValue, }); + const crmRepository = globalThis.ArchPanelCrmRepository.create({ + API_ENDPOINTS, + COMCIP_REQUEST, + fetchJson, + sendComcipRequest: sendComcipRequestMessage, + cleanString, + getFieldValue, + toNumber, + }); + const comcipRepository = globalThis.ArchPanelComcipRepository.create({ COMCIP_REQUEST, sendComcipRequest: sendComcipRequestMessage, @@ -802,6 +812,7 @@ DETAIL_SORT_KEYS, hcmPage, workbenchRepository, + crmRepository, comcipRepository, readCachedDataset, writeCachedDataset, @@ -819,7 +830,7 @@ formatWholeNumber, formatProgressPercent, formatDate, - teamContainsUser, + indexServiceRequestsByOpportunity, stripTransientFields, createExportPayload, createDatasetSnapshot, diff --git a/manifest.json b/manifest.json index 32851b6..b294bfd 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Arch Panel", - "version": "1.0.10", + "version": "1.0.11", "description": "Insere o botao Arch panel no header do Oracle Workload Workbench.", "permissions": [ "declarativeNetRequestWithHostAccess", @@ -21,7 +21,9 @@ "content_scripts": [ { "matches": [ - "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html*" + "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html*", + "https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", + "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*" ], "js": [ "page-bridge.js" @@ -47,6 +49,7 @@ "src/services/transport-service.js", "src/repositories/cache-repository.js", "src/repositories/workbench-repository.js", + "src/repositories/crm-repository.js", "src/repositories/comcip-repository.js", "src/domain/ramp-forecast.js", "src/domain/workload-dataset.js", diff --git a/page-bridge.js b/page-bridge.js index 6b31514..a502a4d 100644 --- a/page-bridge.js +++ b/page-bridge.js @@ -7,6 +7,8 @@ const MESSAGE_SOURCE = "arch-panel-extension"; const API_PATH_FRAGMENT = "/oalcrm/web/api/"; + const CRM_ORIGIN = "https://eeho.fa.us2.oraclecloud.com"; + const CRM_SCOPE = `scope:crm-rest:${CRM_ORIGIN}`; const EXTRA_CAPTURE_ORIGINS = new Set([ "https://comcipapic-oalprod.integration.ocp.oraclecloud.com", ]); @@ -147,7 +149,7 @@ return false; } - if (url.includes(API_PATH_FRAGMENT)) { + if (isCrmRestUrl(url) || url.includes(API_PATH_FRAGMENT)) { return true; } @@ -158,6 +160,15 @@ } } + function isCrmRestUrl(url) { + try { + const parsedUrl = new URL(url, window.location.origin); + return parsedUrl.origin === CRM_ORIGIN && parsedUrl.pathname.startsWith("/crmRestApi/"); + } catch { + return false; + } + } + function normalizeHeaders(input) { const normalized = new Map(); @@ -194,6 +205,10 @@ try { const parsedUrl = new URL(normalizedUrl); const path = parsedUrl.pathname; + if (isCrmRestUrl(normalizedUrl)) { + // CRM credentials belong to the Fusion origin, not to the SPA provider. + return [CRM_SCOPE, `crm-path:${parsedUrl.origin}${path}`]; + } const keys = [`path:${path}`]; const providerProxyMatch = path.match( /\/provider-proxy\/([^/]+)\// @@ -258,7 +273,7 @@ } headers.forEach((value, key) => { - if (!isAuthorizationLikeHeader(key)) { + if (!isCrmRestUrl(url) && !isAuthorizationLikeHeader(key)) { bridgeState.globalHeaders.set(key, value); } }); @@ -273,6 +288,9 @@ } function publishCapturedAuthHeaders(url, headers) { + if (isCrmRestUrl(url)) { + return; + } const authHeaders = {}; headers.forEach((value, key) => { @@ -299,9 +317,11 @@ const merged = new Headers(); const activeScopeKeys = getScopeKeys(url); - bridgeState.globalHeaders.forEach((value, key) => { - merged.set(key, value); - }); + if (!isCrmRestUrl(url)) { + bridgeState.globalHeaders.forEach((value, key) => { + merged.set(key, value); + }); + } for (const scopeKey of activeScopeKeys) { const bucket = bridgeState.scopedHeaders.get(scopeKey); @@ -395,8 +415,11 @@ snapshot.set(key, value); }); - if (options.includeAllAuth) { - bridgeState.scopedHeaders.forEach((bucket) => { + if (options.includeAllAuth && !isCrmRestUrl(scopeUrl)) { + bridgeState.scopedHeaders.forEach((bucket, scopeKey) => { + if (scopeKey === CRM_SCOPE || scopeKey.startsWith("crm-path:")) { + return; + } bucket.forEach((value, key) => { if (isAuthorizationLikeHeader(key)) { snapshot.set(key, value); diff --git a/src/content/workbench-controller.js b/src/content/workbench-controller.js index c9c3bb4..0885509 100644 --- a/src/content/workbench-controller.js +++ b/src/content/workbench-controller.js @@ -5,6 +5,7 @@ DETAIL_SORT_KEYS, hcmPage, workbenchRepository, + crmRepository, comcipRepository, readCachedDataset, writeCachedDataset, @@ -22,7 +23,7 @@ formatWholeNumber, formatProgressPercent, formatDate, - teamContainsUser, + indexServiceRequestsByOpportunity, stripTransientFields, createExportPayload, createDatasetSnapshot, @@ -101,11 +102,24 @@ })); }); + updateLoadingProgress("Loading CRM service requests by opportunity", 54); + const opportunityServiceRequests = await crmRepository.fetchOpportunityServiceRequests({ + onPage: ({ page, loaded }) => { + updateLoadingProgress( + `Loaded CRM SR page ${page}: ${formatWholeNumber(loaded)} service requests`, + 56 + ); + }, + }); + const serviceRequestsByOpportunity = indexServiceRequestsByOpportunity( + opportunityServiceRequests + ); + updateLoadingProgress( - `Loading actions and service requests for ${formatWholeNumber( + `Loading actions for ${formatWholeNumber( allWorkloads.length )} workloads`, - 56 + 60 ); let completedWorkloads = 0; @@ -114,27 +128,22 @@ 6, async (workload, index) => { updateLoadingProgress( - `Loading actions and SRs ${index + 1}/${allWorkloads.length}: ${workload.name}`, - getSegmentProgress(56, 86, completedWorkloads, allWorkloads.length) + `Loading actions ${index + 1}/${allWorkloads.length}: ${workload.name}`, + getSegmentProgress(60, 86, completedWorkloads, allWorkloads.length) ); - const [actions, serviceRequests] = await Promise.all([ - workbenchRepository.fetchWorkloadActions(workload.workloadId), - workbenchRepository.fetchWorkloadServiceRequests(workload.workloadId), - ]); + const actions = await workbenchRepository.fetchWorkloadActions(workload.workloadId); + const serviceRequests = serviceRequestsByOpportunity.get( + normalizeString(workload.opportunityNumber) + ) || []; completedWorkloads += 1; updateLoadingProgress( - `Loaded actions and SRs ${completedWorkloads}/${allWorkloads.length}: ${workload.name}`, - getSegmentProgress(56, 86, completedWorkloads, allWorkloads.length) + `Loaded actions ${completedWorkloads}/${allWorkloads.length}: ${workload.name}`, + getSegmentProgress(60, 86, completedWorkloads, allWorkloads.length) ); - const hasSR = serviceRequests.some((serviceRequest) => { - return ( - normalizeString(serviceRequest.status) === - "SVC_PENDING_DELIVERY" && - teamContainsUser(serviceRequest.team, user.userEmail) - ); - }); + // CRM already filters by the current user's SR team and pending status. + const hasSR = serviceRequests.length > 0; const hasAction = actions.some((action) => { return ( diff --git a/src/domain/workload-dataset.js b/src/domain/workload-dataset.js index 28d01a7..08c5167 100644 --- a/src/domain/workload-dataset.js +++ b/src/domain/workload-dataset.js @@ -91,6 +91,20 @@ return normalizeString(team).includes(normalizeString(userEmail)); } + function indexServiceRequestsByOpportunity(serviceRequests) { + const byOpportunity = new Map(); + for (const serviceRequest of serviceRequests) { + const opportunityNumber = normalizeString(serviceRequest.opportunityNumber); + if (!opportunityNumber || !cleanString(serviceRequest.srNumber)) { + continue; + } + const items = byOpportunity.get(opportunityNumber) || []; + items.push(serviceRequest); + byOpportunity.set(opportunityNumber, items); + } + return byOpportunity; + } + function stripTransientFields(workload) { return { workloadId: workload.workloadId, @@ -230,9 +244,10 @@ serviceRequests: extractArray(workload?.serviceRequests, [ "serviceRequests", ]).map((serviceRequest) => ({ + srId: cleanString(serviceRequest?.srId), srNumber: cleanString(serviceRequest?.srNumber), - status: cleanString(serviceRequest?.status), - team: normalizeTeamValue(serviceRequest?.team), + opportunityNumber: cleanString(serviceRequest?.opportunityNumber), + reportedHours: toNumber(serviceRequest?.reportedHours), })), hasSR: Boolean(workload?.hasSR), hasAction: Boolean(workload?.hasAction), @@ -309,6 +324,7 @@ normalizeForecastType, normalizeTeamValue, teamContainsUser, + indexServiceRequestsByOpportunity, stripTransientFields, createExportPayload, createDatasetSnapshot, diff --git a/src/repositories/crm-repository.js b/src/repositories/crm-repository.js new file mode 100644 index 0000000..d53b3ff --- /dev/null +++ b/src/repositories/crm-repository.js @@ -0,0 +1,130 @@ +(() => { + function create({ API_ENDPOINTS, COMCIP_REQUEST, fetchJson, sendComcipRequest, cleanString, getFieldValue, toNumber }) { + async function fetchOpportunityServiceRequests({ onPage } = {}) { + const limit = 15; + let offset = 0; + let page = 0; + let useAuthenticatedProxy = false; + const serviceRequests = []; + const seen = new Set(); + + while (true) { + const query = createServiceRequestQuery(limit, offset); + let payload; + if (useAuthenticatedProxy) { + payload = await fetchAuthenticatedQuery(query); + } else { + try { + payload = await fetchJson(API_ENDPOINTS.opportunityServiceRequests, { + method: "POST", + forcePageBridge: true, + headers: { "content-type": "application/json" }, + body: JSON.stringify(query), + }); + } catch (error) { + if (error?.status !== 401 && error?.status !== 403) { + throw error; + } + // FuseWelcome can lack a CRM REST session. The existing COMCIP + // proxy authenticates against the SAME CRM queries endpoint. + // Retry this offset and keep subsequent pages on that transport. + useAuthenticatedProxy = true; + payload = await fetchAuthenticatedQuery(query); + } + } + + // Reject login HTML or incomplete responses instead of marking every + // opportunity as having no SRs. hasMore is the pagination authority. + if (!Array.isArray(payload?.items) || typeof payload.hasMore !== "boolean") { + throw new Error("Invalid CRM service request response: expected items and hasMore."); + } + if (payload.offset !== undefined && payload.offset !== offset) { + throw new Error("CRM service request pagination returned an unexpected offset."); + } + + const previousSize = seen.size; + for (const item of payload.items) { + const srNumber = cleanString(item?.SrNumber); + const srId = cleanString(item?.SrId); + const key = srId || srNumber; + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + serviceRequests.push({ + srId, + srNumber, + opportunityNumber: cleanString( + getFieldValue(item, ["OpportunityName_Id_c.OptyNumber"]) + ), + reportedHours: toNumber(item?.ReportedHours_c), + }); + } + + page += 1; + onPage?.({ page, loaded: serviceRequests.length, hasMore: payload.hasMore }); + if (!payload.hasMore) { + return serviceRequests; + } + if (!payload.items.length || seen.size === previousSize) { + throw new Error("CRM service request pagination did not advance while hasMore is true."); + } + offset += payload.items.length; + } + } + + async function fetchAuthenticatedQuery(query) { + const response = await sendComcipRequest({ + method: "POST", + url: COMCIP_REQUEST.url, + payload: query, + timeoutMessage: "Timed out while querying CRM through the authenticated COMCIP session.", + }); + if (!response?.ok) { + const status = response?.status || 0; + const hint = status === 401 || status === 403 + ? " Sign in to COMCIP and retry Refresh data." + : ""; + const error = new Error(`CRM query through the authenticated COMCIP session failed (${status}).${hint}`); + error.status = status; + throw error; + } + return response.payload; + } + + function createServiceRequestQuery(limit, offset) { + return { + aggregationResults: false, + applications: ["ORACLE-ISS-APP"], + onlyData: true, + entity: "CRMServiceRequest", + copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205", + q: { + op: "$and", + criteria: [ + { op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }, + { op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] }, + ], + }, + keywords: null, + fields: [ + "SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", + "_actions", "DoNotCallFlag", "DoNotEmailFlag", + ], + sort: [{ attribute: "_score", direction: "descending" }], + language: "en", + keywordsFields: [ + "SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", + "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner", + ], + skipInValidFields: true, + limit, + offset, + }; + } + + return Object.freeze({ fetchOpportunityServiceRequests }); + } + + globalThis.ArchPanelCrmRepository = Object.freeze({ create }); +})(); diff --git a/src/repositories/workbench-repository.js b/src/repositories/workbench-repository.js index 9207bbc..6776050 100644 --- a/src/repositories/workbench-repository.js +++ b/src/repositories/workbench-repository.js @@ -399,30 +399,6 @@ })); } - async function fetchWorkloadServiceRequests(workloadId) { - const url = new URL( - `${API_ENDPOINTS.workloadServiceRequests}/${encodeURIComponent( - workloadId - )}/serviceRequests`, - TARGET_ORIGIN - ); - - const payload = await fetchJson(url.toString()); - const items = extractArray(payload, [ - "items", - "serviceRequests", - "content", - "results", - "data", - ]); - - return items.map((item) => ({ - srNumber: item?.srNumber || "", - status: item?.status || "", - team: normalizeTeamValue(item?.team), - })); - } - async function createWorkloadAction(payload) { return fetchJson(API_ENDPOINTS.workloadActions, { method: "POST", @@ -434,7 +410,7 @@ } async function updateWorkloadForecast(workloadId, payload) { - const url = `${API_ENDPOINTS.workloadServiceRequests}/${encodeURIComponent( + const url = `${API_ENDPOINTS.workloads}/${encodeURIComponent( workloadId )}/forecast`; @@ -455,7 +431,6 @@ fetchAllCustomers, fetchCustomerWorkloads, fetchWorkloadActions, - fetchWorkloadServiceRequests, createWorkloadAction, updateWorkloadForecast, }; diff --git a/src/services/transport-service.js b/src/services/transport-service.js index fc81fd3..94b671d 100644 --- a/src/services/transport-service.js +++ b/src/services/transport-service.js @@ -227,7 +227,7 @@ const debugSuffix = debugParts.length > 0 ? ` | ${debugParts.join(" | ")}` : ""; - throw new Error( + const error = new Error( `${ payloadMessage || response.error || @@ -236,6 +236,10 @@ )}` }${debugSuffix}` ); + // Preserve the HTTP status so callers can recover authentication + // failures without interpreting human-readable server messages. + error.status = response.status; + throw error; } return response.payload; diff --git a/src/shared/config.js b/src/shared/config.js index fcc5ccc..3d8d093 100644 --- a/src/shared/config.js +++ b/src/shared/config.js @@ -49,7 +49,7 @@ dbName: "arch-panel-extension-db", dbVersion: 1, storeName: "datasets", - datasetKey: "workload-dashboard", + datasetKey: "workload-dashboard-opportunity-srs", taskTypeKey: "time-management-task-types", worklistTokenKey: "worklist-saasui-token", }; @@ -86,8 +86,10 @@ "/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/customers", workloadActions: "/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", - workloadServiceRequests: + workloads: "/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads", + opportunityServiceRequests: + "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries", }; const COMCIP_REQUEST = { diff --git a/tests/opportunity-service-requests.test.js b/tests/opportunity-service-requests.test.js new file mode 100644 index 0000000..218d9e8 --- /dev/null +++ b/tests/opportunity-service-requests.test.js @@ -0,0 +1,413 @@ +const assert = require("node:assert/strict"); +const { readFileSync } = require("node:fs"); +const path = require("node:path"); +const { test } = require("node:test"); +const vm = require("node:vm"); + +require("../src/shared/config.js"); +require("../src/shared/utils.js"); +require("../src/domain/workload-dataset.js"); +require("../src/repositories/crm-repository.js"); +require("../src/repositories/workbench-repository.js"); +require("../src/repositories/cache-repository.js"); + +const config = globalThis.ArchPanelConfig; +const utils = globalThis.ArchPanelUtils; +const domain = globalThis.ArchPanelWorkloadDatasetDomain.create({ + ...config, ...utils, normalizePendingServiceRequests: (items) => items, +}); +const source = (file) => readFileSync(path.join(__dirname, "..", file), "utf8"); +const createCrm = (fetchJson, sendComcipRequest) => globalThis.ArchPanelCrmRepository.create({ + ...config, ...utils, fetchJson, sendComcipRequest, +}); +const sr = (index, optyNumber = `OPTY-${index}`) => ({ + _entity: "CRMServiceRequest", + SrId: 300000000000000 + index, + SrNumber: `SR-${index}`, + OpportunityName_Id_c: { OptyNumber: optyNumber }, + ReportedHours_c: index / 2, +}); + +test("fetches all 83 SRs in six POST pages with the requested filters and projection", async () => { + const rows = Array.from({ length: 83 }, (_, i) => sr(i)); + const requests = []; + const progress = []; + const repository = createCrm(async (url, options) => { + assert.equal(url, config.API_ENDPOINTS.opportunityServiceRequests); + assert.equal(options.method, "POST"); + assert.equal(options.forcePageBridge, true); + assert.equal(options.headers["content-type"], "application/json"); + const payload = JSON.parse(options.body); + requests.push(payload); + return { + items: rows.slice(payload.offset, payload.offset + payload.limit), + hasMore: payload.offset + payload.limit < rows.length, + offset: payload.offset, limit: 15, totalResults: 83, + }; + }); + const items = await repository.fetchOpportunityServiceRequests({ onPage: (value) => progress.push(value) }); + assert.equal(items.length, 83); + assert.deepEqual(requests.map((request) => request.offset), [0, 15, 30, 45, 60, 75]); + assert.ok(requests.every((request) => request.limit === 15)); + assert.deepEqual(requests[0], { + aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, + entity: "CRMServiceRequest", copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205", + q: { op: "$and", criteria: [ + { op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }, + { op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] }, + ] }, + keywords: null, + fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "_actions", "DoNotCallFlag", "DoNotEmailFlag"], + sort: [{ attribute: "_score", direction: "descending" }], language: "en", + keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"], + skipInValidFields: true, limit: 15, offset: 0, + }); + assert.deepEqual(items[82], { + srId: "300000000000082", srNumber: "SR-82", opportunityNumber: "OPTY-82", reportedHours: 41, + }); + assert.deepEqual(progress.at(-1), { page: 6, loaded: 83, hasMore: false }); +}); + +test("hasMore controls pagination even on short pages or misleading totals; duplicate SRs are deduplicated", async () => { + const offsets = []; + const pages = [ + { items: [sr(1)], hasMore: true, totalResults: 1 }, + { items: [sr(1), sr(2)], hasMore: false, totalResults: 1000 }, + ]; + const items = await createCrm(async (_, options) => { + offsets.push(JSON.parse(options.body).offset); + return pages.shift(); + }).fetchOpportunityServiceRequests(); + assert.deepEqual(offsets, [0, 1]); + assert.deepEqual(items.map((item) => item.srNumber), ["SR-1", "SR-2"]); + assert.deepEqual(await createCrm(async () => ({ items: [], hasMore: false })).fetchOpportunityServiceRequests(), []); +}); + +test("invalid responses and stalled pagination fail instead of generating false SR coverage", async () => { + for (const payload of ["Sign in", { items: [] }, { items: [], hasMore: true }, { items: [sr(1)], hasMore: true, offset: 15 }]) { + await assert.rejects(createCrm(async () => payload).fetchOpportunityServiceRequests(), /response|pagination/); + } + let calls = 0; + await assert.rejects(createCrm(async () => { + calls += 1; + return { items: [sr(1)], hasMore: true }; + }).fetchOpportunityServiceRequests(), /did not advance/); + assert.equal(calls, 2); +}); + +function createDashboardHarness({ failSecondPage = false } = {}) { + const calls = []; + const cached = []; + const appState = { status: "idle", dataset: null }; + // Different workloads, including two on the same opportunity. Same customer + // is not sufficient to grant SR coverage; missing OptyNumber must not match. + const workloadOpportunities = ["A5LLT2", " a5llt2 ", "A7H8FT", "NO-SR", "", "A5LLT"]; + const fetchJson = async (url, options = {}) => { + calls.push({ url, options }); + if (url === config.API_ENDPOINTS.currentUser) { + return { email: "architect@example.com", name: "Architect" }; + } + if (url === config.API_ENDPOINTS.opportunityServiceRequests) { + const { offset } = JSON.parse(options.body); + if (offset === 0) { + return { items: [sr(1, "A5LLT2"), sr(2, "")], offset, hasMore: true }; + } + assert.equal(offset, 2); + if (failSecondPage) throw Object.assign(new Error("CRM page failed (500)"), { status: 500 }); + return { items: [sr(3, "A7H8FT"), sr(4, "A5LLT2")], offset, hasMore: false }; + } + const parsed = new URL(url, config.TARGET_ORIGIN); + if (parsed.pathname === config.API_ENDPOINTS.customerSummary) { + return [{ id: "customer-1", name: "Customer" }]; + } + if (parsed.pathname === `${config.API_ENDPOINTS.customerWorkloads}/customer-1/workloads`) { + return workloadOpportunities.map((opportunityNumber, index) => ({ + workloadId: `w-${index}`, name: `Workload ${index}`, opportunityNumber, + opportunityForecastType: "Pipeline", workloadStatus: "Pipeline", + })); + } + if (parsed.pathname === config.API_ENDPOINTS.workloadActions) { + return [{ owner: "architect@example.com", name: "Action" }]; + } + assert.fail(`Unexpected request: ${url}`); + }; + const workbenchRepository = globalThis.ArchPanelWorkbenchRepository.create({ ...config, ...utils, ...domain, fetchJson }); + const context = vm.createContext({ + document: { getElementById: () => null }, + window: { requestAnimationFrame: (callback) => callback() }, + }); + vm.runInContext(source("src/content/workbench-controller.js"), context); + const controller = context.ArchPanelWorkbenchController.create({ + ...config, ...utils, ...domain, appState, workbenchRepository, + crmRepository: createCrm(fetchJson), + comcipRepository: { fetchResourceCurrentUser: async () => null, fetchPendingServiceRequests: async () => [] }, + hcmPage: { syncWorklistIframeToken: () => null }, + readCachedWorklistToken: async () => "", + readCachedDataset: async () => null, + writeCachedDataset: async (payload) => { cached.push(payload); }, + refreshTaskTypeCache: async () => null, + renderModal() {}, + }); + return { controller, appState, cached, calls, workbenchRepository }; +} + +test("dashboard joins all pages by OptyNumber, keeps per-workload actions and persists the opportunity SR data", async () => { + const { controller, appState, cached, calls, workbenchRepository } = createDashboardHarness(); + await controller.refreshData(); + assert.equal(appState.status, "ready", appState.errorMessage); + const workloads = appState.dataset.summary.allWorkloads; + assert.deepEqual(Array.from(workloads, (item) => item.hasSR), [true, true, true, false, false, false]); + assert.ok(workloads.every((item) => item.hasAction)); + assert.equal(workloads[0].serviceRequests.length, 2); + assert.equal(workloads[2].serviceRequests[0].srNumber, "SR-3"); + assert.equal(cached.length, 1); + const restored = domain.createDatasetSnapshot(cached[0]); + assert.equal(restored.summary.allWorkloads[2].serviceRequests[0].opportunityNumber, "A7H8FT"); + assert.equal(restored.summary.allWorkloads[2].serviceRequests[0].reportedHours, 1.5); + assert.equal(restored.summary.srStatus.trueCount, 3); + assert.equal(calls.filter((call) => call.url === config.API_ENDPOINTS.opportunityServiceRequests).length, 2); + assert.equal(workbenchRepository.fetchWorkloadServiceRequests, undefined); + assert.ok(calls.every((call) => !/\/workloads\/[^/]+\/serviceRequests/.test(call.url))); + await controller.refreshData(); + assert.equal(appState.status, "ready", appState.errorMessage); + assert.equal(calls.filter((call) => call.url === config.API_ENDPOINTS.opportunityServiceRequests).length, 4); +}); + +test("failed later CRM pages never save a partial dashboard or fall back to workload SRs", async () => { + const { controller, appState, cached, calls } = createDashboardHarness({ failSecondPage: true }); + await controller.refreshData(); + assert.equal(appState.status, "error"); + assert.match(appState.errorMessage, /500/); + assert.equal(cached.length, 0); + assert.equal(appState.dataset, null); + assert.ok(calls.every((call) => !/\/workloads\/[^/]+\/serviceRequests/.test(call.url))); +}); + +test("old workload-based snapshots are not reused; forecast still targets the workload endpoint", async () => { + let cacheKey; + const cache = globalThis.ArchPanelCacheRepository.create({ + ...utils, CACHE: config.CACHE, normalizeCachedExportPayload: domain.normalizeCachedExportPayload, + storageService: { readRecord: async (_, key) => { cacheKey = key; return null; } }, + }); + assert.equal(await cache.readCachedDataset(), null); + assert.equal(cacheKey, "workload-dashboard-opportunity-srs"); + let request; + const repository = globalThis.ArchPanelWorkbenchRepository.create({ + ...config, ...utils, ...domain, fetchJson: async (url, options) => { request = { url, options }; }, + }); + await repository.updateWorkloadForecast("w-1", { forecast: [] }); + assert.equal(request.url, `${config.API_ENDPOINTS.workloads}/w-1/forecast`); + assert.equal(request.options.method, "PATCH"); +}); + +function createPageSession(pathname = "/hcmUI/faces/FuseWelcome", { queryStatus = 200, sendMessage } = {}) { + const networkCalls = []; + const listeners = []; + class MockXhr { + open() {} + setRequestHeader() {} + send() {} + } + const window = { + location: new URL(`https://eeho.fa.us2.oraclecloud.com${pathname}`), + localStorage: { length: 0 }, sessionStorage: { length: 0 }, + XMLHttpRequest: MockXhr, + setTimeout, clearTimeout, + addEventListener: (_, listener) => listeners.push(listener), + postMessage: (data) => { for (const listener of listeners) listener({ source: window, origin: window.location.origin, data }); }, + fetch: async (url, options) => { + networkCalls.push({ url, options }); + const offset = options.body ? JSON.parse(options.body).offset : 0; + const status = String(url).includes("/custom-actions/queries") ? queryStatus : 200; + return { ok: status === 200, status, text: async () => status !== 200 ? "" : JSON.stringify({ + items: [sr(offset + 1)], hasMore: offset === 0, offset, + }) }; + }, + }; + window.top = window; + const context = vm.createContext({ + window, URL, Headers, + navigator: { language: "en" }, + document: { getElementById: () => ({}) }, + }); + vm.runInContext(source("page-bridge.js"), context); + vm.runInContext(source("src/services/transport-service.js"), context); + const transport = context.ArchPanelTransportService.create({ + ...config, ...utils, + chromeService: { sendMessage: sendMessage || (() => assert.fail("Authenticated CRM calls must use the Fusion page session")) }, + isHcmWelcomePage: () => true, + getSessionAuthHeaders: () => ({ "wwb-provider-authorization": "synthetic-spa-token" }), + setSessionAuthHeaders() {}, + }); + window.addEventListener("message", transport.handleBridgeMessage); + return { window, transport, networkCalls }; +} + +for (const pagePath of ["/hcmUI/faces/FuseWelcome", "/fscmUI/faces/FuseWelcome"]) { + test(`CRM pagination reuses captured headers and cookies through the real page bridge on ${pagePath}`, async () => { + const { window, transport, networkCalls } = createPageSession(pagePath); + const headers = { + authorization: "Bearer synthetic-crm-token", + "x-csrf-token": "synthetic-csrf", + "rest-framework-version": "4", + }; + await window.fetch("/crmRestApi/resources/latest/serviceRequests", { headers }); + // Capture unrelated SPA traffic after CRM traffic to exercise isolation. + await window.fetch(`${config.TARGET_ORIGIN}${config.API_ENDPOINTS.workloads}/w-1`, { + headers: { authorization: "Bearer wrong-spa-token", "wwb-provider-authorization": "synthetic-provider-token" }, + }); + await createCrm(transport.fetchJson).fetchOpportunityServiceRequests(); + const calls = networkCalls.filter((call) => String(call.url).includes("/custom-actions/queries")); + assert.equal(calls.length, 2); + for (const { options } of calls) { + assert.equal(options.credentials, "include"); + assert.equal(options.method, "POST"); + for (const [name, value] of Object.entries(headers)) assert.equal(options.headers.get(name), value); + assert.equal(options.headers.get("wwb-provider-authorization"), null); + assert.equal(options.headers.get("content-type"), "application/json"); + } + const snapshot = window.__archPanelExtensionBridge.buildHeaderSnapshot({ + url: config.API_ENDPOINTS.opportunityServiceRequests, includeAllAuth: true, + }); + assert.equal(snapshot.authorization, headers.authorization); + assert.equal(snapshot["wwb-provider-authorization"], undefined); + }); +} + +test("CRM headers from XHR are reused and updated without leaking into SPA snapshots", async () => { + const { window, transport, networkCalls } = createPageSession(); + const xhr = new window.XMLHttpRequest(); + xhr.open("POST", config.API_ENDPOINTS.opportunityServiceRequests); + xhr.setRequestHeader("authorization", "Bearer synthetic-refreshed-token"); + xhr.setRequestHeader("x-csrf-token", "synthetic-refreshed-csrf"); + xhr.send(); + await createCrm(transport.fetchJson).fetchOpportunityServiceRequests(); + assert.equal(networkCalls.at(-1).options.headers.get("authorization"), "Bearer synthetic-refreshed-token"); + assert.equal(networkCalls.at(-1).options.headers.get("x-csrf-token"), "synthetic-refreshed-csrf"); + const snapshot = window.__archPanelExtensionBridge.buildHeaderSnapshot({ + url: `${config.TARGET_ORIGIN}${config.API_ENDPOINTS.workloads}/w-1`, includeAllAuth: true, + }); + assert.equal(snapshot.authorization, undefined); + assert.equal(snapshot["x-csrf-token"], undefined); +}); + +test("manifest loads the CRM repository and captures Fusion headers at document_start", () => { + const manifest = JSON.parse(source("manifest.json")); + const bridge = manifest.content_scripts.find((entry) => entry.world === "MAIN"); + assert.equal(bridge.run_at, "document_start"); + for (const route of ["hcmUI", "fscmUI"]) { + assert.ok(bridge.matches.includes(`https://eeho.fa.us2.oraclecloud.com/${route}/faces/FuseWelcome*`)); + } + const scripts = manifest.content_scripts.find((entry) => entry.js.includes("content-script.js")).js; + assert.ok(scripts.indexOf("src/repositories/crm-repository.js") < scripts.indexOf("content-script.js")); +}); + +for (const status of [401, 403]) { + test(`a Fusion ${status} with no captured auth recovers through COMCIP and reads all 83 SRs`, async () => { + const rows = Array.from({ length: 83 }, (_, i) => sr(i)); + const proxyCalls = []; + const { transport, networkCalls } = createPageSession("/hcmUI/faces/FuseWelcome", { + queryStatus: status, + sendMessage: async (message) => { + proxyCalls.push(message); + const { offset, limit } = message.payload; + return { ok: true, status: 200, payload: { + items: rows.slice(offset, offset + limit), offset, + hasMore: offset + limit < rows.length, + } }; + }, + }); + const repository = createCrm(transport.fetchJson, transport.sendComcipRequestMessage); + const items = await repository.fetchOpportunityServiceRequests(); + assert.equal(items.length, 83); + assert.equal(networkCalls.length, 1, "Do not repeat failed direct auth on each page"); + assert.equal(networkCalls[0].options.headers.get("authorization"), null); + assert.deepEqual(proxyCalls.map((message) => message.payload.offset), [0, 15, 30, 45, 60, 75]); + assert.deepEqual(proxyCalls[0].payload, JSON.parse(networkCalls[0].options.body)); + for (const message of proxyCalls) { + assert.equal(message.type, "ARCH_PANEL_COMCIP_REQUEST"); + assert.equal(message.method, "POST"); + assert.equal(message.url, config.COMCIP_REQUEST.url); + assert.ok(message.url.endsWith("/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries")); + assert.equal(message.headers["x-vb-application-version"], config.COMCIP_REQUEST.appVersion); + assert.equal(message.headers["wwb-provider-authorization"], undefined); + assert.equal(message.payload.q.criteria[0].value, "ORA_SVC_MY_SR_TEAM_SRS"); + assert.deepEqual(message.payload.q.criteria[1].values, ["SVC_PENDING_DELIVERY"]); + } + await repository.fetchOpportunityServiceRequests(); + assert.equal(networkCalls.length, 2, "A fresh refresh can use a restored Fusion session"); + }); +} + +test("an auth failure mid-pagination retries the same offset through COMCIP", async () => { + const directOffsets = []; + const proxyOffsets = []; + const rows = Array.from({ length: 32 }, (_, i) => sr(i)); + const items = await createCrm(async (_, options) => { + const { offset, limit } = JSON.parse(options.body); + directOffsets.push(offset); + if (offset === 15) throw Object.assign(new Error("Unauthorized"), { status: 401 }); + return { items: rows.slice(offset, offset + limit), hasMore: true, offset }; + }, async ({ payload: { offset, limit } }) => { + proxyOffsets.push(offset); + return { ok: true, status: 200, payload: { + items: rows.slice(offset, offset + limit), hasMore: offset + limit < rows.length, offset, + } }; + }).fetchOpportunityServiceRequests(); + assert.deepEqual(directOffsets, [0, 15]); + assert.deepEqual(proxyOffsets, [15, 30]); + assert.equal(items.length, 32); + assert.equal(items.at(-1).srNumber, "SR-31"); +}); + +test("non-auth failures do not switch routes; proxy failures are surfaced without retry loops", async () => { + let proxyCalls = 0; + const failedDirect = Object.assign(new Error("Server failed"), { status: 500 }); + await assert.rejects(createCrm(async () => { throw failedDirect; }, async () => { proxyCalls += 1; }) + .fetchOpportunityServiceRequests(), (error) => error === failedDirect); + assert.equal(proxyCalls, 0); + await assert.rejects(createCrm(async () => { + throw Object.assign(new Error("Unauthorized"), { status: 401 }); + }, async () => { + proxyCalls += 1; + return { ok: false, status: 401 }; + }).fetchOpportunityServiceRequests(), (error) => error.status === 401 && /Sign in to COMCIP/.test(error.message)); + assert.equal(proxyCalls, 1); +}); + +test("the existing background proxy fetch uses its own Session auth, negotiated client ID and cookies", async () => { + const calls = []; + const browserContext = vm.createContext({ + window: { location: new URL("https://comcipapic-oalprod.integration.ocp.oraclecloud.com/") }, + navigator: { language: "en" }, + fetch: async (url, options) => { + calls.push({ url, options }); + if (options.method === "GET") { + return { headers: new Headers({ "x-appbuilder-client-id": "synthetic-client-id" }) }; + } + return { ok: true, status: 200, headers: new Headers(), text: async () => JSON.stringify({ items: [sr(1)], hasMore: false }) }; + }, + }); + const workerContext = vm.createContext({ + chrome: { + runtime: { onInstalled: { addListener() {} }, onStartup: { addListener() {} }, onMessage: { addListener() {} } }, + scripting: { executeScript: async ({ func, args }) => { + browserContext.args = args; + const result = await vm.runInContext(`(${func.toString()})(...args)`, browserContext); + return [{ result }]; + } }, + }, + }); + workerContext.importScripts = (file) => vm.runInContext(source(file), workerContext); + vm.runInContext(source("background.js"), workerContext); + const query = { entity: "CRMServiceRequest", offset: 15, limit: 15 }; + const response = await workerContext.executeComcipFetch(1, config.COMCIP_REQUEST.url, "POST", query, {}, {}); + assert.equal(response.ok, true); + const sent = calls.at(-1); + assert.equal(sent.url, config.COMCIP_REQUEST.url); + assert.equal(sent.options.headers.authorization, "Session"); + assert.equal(sent.options.headers["x-appbuilder-client-id"], "synthetic-client-id"); + assert.equal(sent.options.headers["vb-proxy-header-preference"], "transient"); + assert.equal(sent.options.credentials, "include"); + assert.deepEqual(JSON.parse(sent.options.body), query); +});