Compare commits
1 Commits
086cad88f5
...
1.0.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
343d9d7728
|
44
README.md
44
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/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/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/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/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/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.
|
- `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/
|
repositories/
|
||||||
cache-repository.js
|
cache-repository.js
|
||||||
comcip-repository.js
|
comcip-repository.js
|
||||||
|
crm-repository.js
|
||||||
workbench-repository.js
|
workbench-repository.js
|
||||||
views/
|
views/
|
||||||
dashboard-view.js
|
dashboard-view.js
|
||||||
@@ -93,6 +95,26 @@ Requests, cache especifico da extensao e normalizadores de payload de APIs exter
|
|||||||
## Fluxos principais
|
## Fluxos principais
|
||||||
|
|
||||||
- Workload dashboard: carrega usuario, customers, workloads, actions e service requests.
|
- 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`.
|
- 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.
|
- 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`.
|
- 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:
|
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.
|
- `time-management-task-types`: cache de task types atualizado apenas em refresh data.
|
||||||
- `worklist-saasui-token`: token atual extraido do iframe Worklist.
|
- `worklist-saasui-token`: token atual extraido do iframe Worklist.
|
||||||
|
|
||||||
@@ -120,6 +144,13 @@ Chaves conhecidas:
|
|||||||
|
|
||||||
## Validacao local
|
## 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
|
```powershell
|
||||||
node --check content-script.js
|
node --check content-script.js
|
||||||
node --check background.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/templates.js
|
||||||
node --check src/content/workbench-controller.js
|
node --check src/content/workbench-controller.js
|
||||||
node --check src/repositories/workbench-repository.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/comcip-repository.js
|
||||||
node --check src/repositories/cache-repository.js
|
node --check src/repositories/cache-repository.js
|
||||||
node --check src/domain/ramp-forecast.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`.
|
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:
|
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.
|
||||||
```powershell
|
|
||||||
powershell -ExecutionPolicy Bypass -File tools/bump-extension-version.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Quando uma versao especifica for solicitada, informe o valor explicitamente:
|
Quando uma versao especifica for solicitada, informe o valor explicitamente:
|
||||||
|
|
||||||
```powershell
|
```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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
normalizeWorkload,
|
normalizeWorkload,
|
||||||
isWonWorkload,
|
isWonWorkload,
|
||||||
normalizeTeamValue,
|
normalizeTeamValue,
|
||||||
teamContainsUser,
|
indexServiceRequestsByOpportunity,
|
||||||
stripTransientFields,
|
stripTransientFields,
|
||||||
createExportPayload,
|
createExportPayload,
|
||||||
createDatasetSnapshot,
|
createDatasetSnapshot,
|
||||||
@@ -657,6 +657,16 @@
|
|||||||
normalizeTeamValue,
|
normalizeTeamValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const crmRepository = globalThis.ArchPanelCrmRepository.create({
|
||||||
|
API_ENDPOINTS,
|
||||||
|
COMCIP_REQUEST,
|
||||||
|
fetchJson,
|
||||||
|
sendComcipRequest: sendComcipRequestMessage,
|
||||||
|
cleanString,
|
||||||
|
getFieldValue,
|
||||||
|
toNumber,
|
||||||
|
});
|
||||||
|
|
||||||
const comcipRepository = globalThis.ArchPanelComcipRepository.create({
|
const comcipRepository = globalThis.ArchPanelComcipRepository.create({
|
||||||
COMCIP_REQUEST,
|
COMCIP_REQUEST,
|
||||||
sendComcipRequest: sendComcipRequestMessage,
|
sendComcipRequest: sendComcipRequestMessage,
|
||||||
@@ -802,6 +812,7 @@
|
|||||||
DETAIL_SORT_KEYS,
|
DETAIL_SORT_KEYS,
|
||||||
hcmPage,
|
hcmPage,
|
||||||
workbenchRepository,
|
workbenchRepository,
|
||||||
|
crmRepository,
|
||||||
comcipRepository,
|
comcipRepository,
|
||||||
readCachedDataset,
|
readCachedDataset,
|
||||||
writeCachedDataset,
|
writeCachedDataset,
|
||||||
@@ -819,7 +830,7 @@
|
|||||||
formatWholeNumber,
|
formatWholeNumber,
|
||||||
formatProgressPercent,
|
formatProgressPercent,
|
||||||
formatDate,
|
formatDate,
|
||||||
teamContainsUser,
|
indexServiceRequestsByOpportunity,
|
||||||
stripTransientFields,
|
stripTransientFields,
|
||||||
createExportPayload,
|
createExportPayload,
|
||||||
createDatasetSnapshot,
|
createDatasetSnapshot,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Arch Panel",
|
"name": "Arch Panel",
|
||||||
"version": "1.0.10",
|
"version": "1.0.11",
|
||||||
"description": "Insere o botao Arch panel no header do Oracle Workload Workbench.",
|
"description": "Insere o botao Arch panel no header do Oracle Workload Workbench.",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"declarativeNetRequestWithHostAccess",
|
"declarativeNetRequestWithHostAccess",
|
||||||
@@ -21,7 +21,9 @@
|
|||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": [
|
"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": [
|
"js": [
|
||||||
"page-bridge.js"
|
"page-bridge.js"
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
"src/services/transport-service.js",
|
"src/services/transport-service.js",
|
||||||
"src/repositories/cache-repository.js",
|
"src/repositories/cache-repository.js",
|
||||||
"src/repositories/workbench-repository.js",
|
"src/repositories/workbench-repository.js",
|
||||||
|
"src/repositories/crm-repository.js",
|
||||||
"src/repositories/comcip-repository.js",
|
"src/repositories/comcip-repository.js",
|
||||||
"src/domain/ramp-forecast.js",
|
"src/domain/ramp-forecast.js",
|
||||||
"src/domain/workload-dataset.js",
|
"src/domain/workload-dataset.js",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
const MESSAGE_SOURCE = "arch-panel-extension";
|
const MESSAGE_SOURCE = "arch-panel-extension";
|
||||||
const API_PATH_FRAGMENT = "/oalcrm/web/api/";
|
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([
|
const EXTRA_CAPTURE_ORIGINS = new Set([
|
||||||
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com",
|
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com",
|
||||||
]);
|
]);
|
||||||
@@ -147,7 +149,7 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.includes(API_PATH_FRAGMENT)) {
|
if (isCrmRestUrl(url) || url.includes(API_PATH_FRAGMENT)) {
|
||||||
return true;
|
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) {
|
function normalizeHeaders(input) {
|
||||||
const normalized = new Map();
|
const normalized = new Map();
|
||||||
|
|
||||||
@@ -194,6 +205,10 @@
|
|||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(normalizedUrl);
|
const parsedUrl = new URL(normalizedUrl);
|
||||||
const path = parsedUrl.pathname;
|
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 keys = [`path:${path}`];
|
||||||
const providerProxyMatch = path.match(
|
const providerProxyMatch = path.match(
|
||||||
/\/provider-proxy\/([^/]+)\//
|
/\/provider-proxy\/([^/]+)\//
|
||||||
@@ -258,7 +273,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
headers.forEach((value, key) => {
|
headers.forEach((value, key) => {
|
||||||
if (!isAuthorizationLikeHeader(key)) {
|
if (!isCrmRestUrl(url) && !isAuthorizationLikeHeader(key)) {
|
||||||
bridgeState.globalHeaders.set(key, value);
|
bridgeState.globalHeaders.set(key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -273,6 +288,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function publishCapturedAuthHeaders(url, headers) {
|
function publishCapturedAuthHeaders(url, headers) {
|
||||||
|
if (isCrmRestUrl(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const authHeaders = {};
|
const authHeaders = {};
|
||||||
|
|
||||||
headers.forEach((value, key) => {
|
headers.forEach((value, key) => {
|
||||||
@@ -299,9 +317,11 @@
|
|||||||
const merged = new Headers();
|
const merged = new Headers();
|
||||||
const activeScopeKeys = getScopeKeys(url);
|
const activeScopeKeys = getScopeKeys(url);
|
||||||
|
|
||||||
|
if (!isCrmRestUrl(url)) {
|
||||||
bridgeState.globalHeaders.forEach((value, key) => {
|
bridgeState.globalHeaders.forEach((value, key) => {
|
||||||
merged.set(key, value);
|
merged.set(key, value);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const scopeKey of activeScopeKeys) {
|
for (const scopeKey of activeScopeKeys) {
|
||||||
const bucket = bridgeState.scopedHeaders.get(scopeKey);
|
const bucket = bridgeState.scopedHeaders.get(scopeKey);
|
||||||
@@ -395,8 +415,11 @@
|
|||||||
snapshot.set(key, value);
|
snapshot.set(key, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.includeAllAuth) {
|
if (options.includeAllAuth && !isCrmRestUrl(scopeUrl)) {
|
||||||
bridgeState.scopedHeaders.forEach((bucket) => {
|
bridgeState.scopedHeaders.forEach((bucket, scopeKey) => {
|
||||||
|
if (scopeKey === CRM_SCOPE || scopeKey.startsWith("crm-path:")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
bucket.forEach((value, key) => {
|
bucket.forEach((value, key) => {
|
||||||
if (isAuthorizationLikeHeader(key)) {
|
if (isAuthorizationLikeHeader(key)) {
|
||||||
snapshot.set(key, value);
|
snapshot.set(key, value);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
DETAIL_SORT_KEYS,
|
DETAIL_SORT_KEYS,
|
||||||
hcmPage,
|
hcmPage,
|
||||||
workbenchRepository,
|
workbenchRepository,
|
||||||
|
crmRepository,
|
||||||
comcipRepository,
|
comcipRepository,
|
||||||
readCachedDataset,
|
readCachedDataset,
|
||||||
writeCachedDataset,
|
writeCachedDataset,
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
formatWholeNumber,
|
formatWholeNumber,
|
||||||
formatProgressPercent,
|
formatProgressPercent,
|
||||||
formatDate,
|
formatDate,
|
||||||
teamContainsUser,
|
indexServiceRequestsByOpportunity,
|
||||||
stripTransientFields,
|
stripTransientFields,
|
||||||
createExportPayload,
|
createExportPayload,
|
||||||
createDatasetSnapshot,
|
createDatasetSnapshot,
|
||||||
@@ -101,11 +102,24 @@
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateLoadingProgress("Loading CRM service requests by opportunity", 54);
|
||||||
|
const opportunityServiceRequests = await crmRepository.fetchOpportunityServiceRequests({
|
||||||
|
onPage: ({ page, loaded }) => {
|
||||||
updateLoadingProgress(
|
updateLoadingProgress(
|
||||||
`Loading actions and service requests for ${formatWholeNumber(
|
`Loaded CRM SR page ${page}: ${formatWholeNumber(loaded)} service requests`,
|
||||||
|
56
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const serviceRequestsByOpportunity = indexServiceRequestsByOpportunity(
|
||||||
|
opportunityServiceRequests
|
||||||
|
);
|
||||||
|
|
||||||
|
updateLoadingProgress(
|
||||||
|
`Loading actions for ${formatWholeNumber(
|
||||||
allWorkloads.length
|
allWorkloads.length
|
||||||
)} workloads`,
|
)} workloads`,
|
||||||
56
|
60
|
||||||
);
|
);
|
||||||
|
|
||||||
let completedWorkloads = 0;
|
let completedWorkloads = 0;
|
||||||
@@ -114,27 +128,22 @@
|
|||||||
6,
|
6,
|
||||||
async (workload, index) => {
|
async (workload, index) => {
|
||||||
updateLoadingProgress(
|
updateLoadingProgress(
|
||||||
`Loading actions and SRs ${index + 1}/${allWorkloads.length}: ${workload.name}`,
|
`Loading actions ${index + 1}/${allWorkloads.length}: ${workload.name}`,
|
||||||
getSegmentProgress(56, 86, completedWorkloads, allWorkloads.length)
|
getSegmentProgress(60, 86, completedWorkloads, allWorkloads.length)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [actions, serviceRequests] = await Promise.all([
|
const actions = await workbenchRepository.fetchWorkloadActions(workload.workloadId);
|
||||||
workbenchRepository.fetchWorkloadActions(workload.workloadId),
|
const serviceRequests = serviceRequestsByOpportunity.get(
|
||||||
workbenchRepository.fetchWorkloadServiceRequests(workload.workloadId),
|
normalizeString(workload.opportunityNumber)
|
||||||
]);
|
) || [];
|
||||||
completedWorkloads += 1;
|
completedWorkloads += 1;
|
||||||
updateLoadingProgress(
|
updateLoadingProgress(
|
||||||
`Loaded actions and SRs ${completedWorkloads}/${allWorkloads.length}: ${workload.name}`,
|
`Loaded actions ${completedWorkloads}/${allWorkloads.length}: ${workload.name}`,
|
||||||
getSegmentProgress(56, 86, completedWorkloads, allWorkloads.length)
|
getSegmentProgress(60, 86, completedWorkloads, allWorkloads.length)
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasSR = serviceRequests.some((serviceRequest) => {
|
// CRM already filters by the current user's SR team and pending status.
|
||||||
return (
|
const hasSR = serviceRequests.length > 0;
|
||||||
normalizeString(serviceRequest.status) ===
|
|
||||||
"SVC_PENDING_DELIVERY" &&
|
|
||||||
teamContainsUser(serviceRequest.team, user.userEmail)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasAction = actions.some((action) => {
|
const hasAction = actions.some((action) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -91,6 +91,20 @@
|
|||||||
return normalizeString(team).includes(normalizeString(userEmail));
|
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) {
|
function stripTransientFields(workload) {
|
||||||
return {
|
return {
|
||||||
workloadId: workload.workloadId,
|
workloadId: workload.workloadId,
|
||||||
@@ -230,9 +244,10 @@
|
|||||||
serviceRequests: extractArray(workload?.serviceRequests, [
|
serviceRequests: extractArray(workload?.serviceRequests, [
|
||||||
"serviceRequests",
|
"serviceRequests",
|
||||||
]).map((serviceRequest) => ({
|
]).map((serviceRequest) => ({
|
||||||
|
srId: cleanString(serviceRequest?.srId),
|
||||||
srNumber: cleanString(serviceRequest?.srNumber),
|
srNumber: cleanString(serviceRequest?.srNumber),
|
||||||
status: cleanString(serviceRequest?.status),
|
opportunityNumber: cleanString(serviceRequest?.opportunityNumber),
|
||||||
team: normalizeTeamValue(serviceRequest?.team),
|
reportedHours: toNumber(serviceRequest?.reportedHours),
|
||||||
})),
|
})),
|
||||||
hasSR: Boolean(workload?.hasSR),
|
hasSR: Boolean(workload?.hasSR),
|
||||||
hasAction: Boolean(workload?.hasAction),
|
hasAction: Boolean(workload?.hasAction),
|
||||||
@@ -309,6 +324,7 @@
|
|||||||
normalizeForecastType,
|
normalizeForecastType,
|
||||||
normalizeTeamValue,
|
normalizeTeamValue,
|
||||||
teamContainsUser,
|
teamContainsUser,
|
||||||
|
indexServiceRequestsByOpportunity,
|
||||||
stripTransientFields,
|
stripTransientFields,
|
||||||
createExportPayload,
|
createExportPayload,
|
||||||
createDatasetSnapshot,
|
createDatasetSnapshot,
|
||||||
|
|||||||
130
src/repositories/crm-repository.js
Normal file
130
src/repositories/crm-repository.js
Normal file
@@ -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 });
|
||||||
|
})();
|
||||||
@@ -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) {
|
async function createWorkloadAction(payload) {
|
||||||
return fetchJson(API_ENDPOINTS.workloadActions, {
|
return fetchJson(API_ENDPOINTS.workloadActions, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -434,7 +410,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateWorkloadForecast(workloadId, payload) {
|
async function updateWorkloadForecast(workloadId, payload) {
|
||||||
const url = `${API_ENDPOINTS.workloadServiceRequests}/${encodeURIComponent(
|
const url = `${API_ENDPOINTS.workloads}/${encodeURIComponent(
|
||||||
workloadId
|
workloadId
|
||||||
)}/forecast`;
|
)}/forecast`;
|
||||||
|
|
||||||
@@ -455,7 +431,6 @@
|
|||||||
fetchAllCustomers,
|
fetchAllCustomers,
|
||||||
fetchCustomerWorkloads,
|
fetchCustomerWorkloads,
|
||||||
fetchWorkloadActions,
|
fetchWorkloadActions,
|
||||||
fetchWorkloadServiceRequests,
|
|
||||||
createWorkloadAction,
|
createWorkloadAction,
|
||||||
updateWorkloadForecast,
|
updateWorkloadForecast,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -227,7 +227,7 @@
|
|||||||
const debugSuffix =
|
const debugSuffix =
|
||||||
debugParts.length > 0 ? ` | ${debugParts.join(" | ")}` : "";
|
debugParts.length > 0 ? ` | ${debugParts.join(" | ")}` : "";
|
||||||
|
|
||||||
throw new Error(
|
const error = new Error(
|
||||||
`${
|
`${
|
||||||
payloadMessage ||
|
payloadMessage ||
|
||||||
response.error ||
|
response.error ||
|
||||||
@@ -236,6 +236,10 @@
|
|||||||
)}`
|
)}`
|
||||||
}${debugSuffix}`
|
}${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;
|
return response.payload;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
dbName: "arch-panel-extension-db",
|
dbName: "arch-panel-extension-db",
|
||||||
dbVersion: 1,
|
dbVersion: 1,
|
||||||
storeName: "datasets",
|
storeName: "datasets",
|
||||||
datasetKey: "workload-dashboard",
|
datasetKey: "workload-dashboard-opportunity-srs",
|
||||||
taskTypeKey: "time-management-task-types",
|
taskTypeKey: "time-management-task-types",
|
||||||
worklistTokenKey: "worklist-saasui-token",
|
worklistTokenKey: "worklist-saasui-token",
|
||||||
};
|
};
|
||||||
@@ -86,8 +86,10 @@
|
|||||||
"/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/customers",
|
"/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/customers",
|
||||||
workloadActions:
|
workloadActions:
|
||||||
"/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions",
|
"/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",
|
"/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 = {
|
const COMCIP_REQUEST = {
|
||||||
|
|||||||
413
tests/opportunity-service-requests.test.js
Normal file
413
tests/opportunity-service-requests.test.js
Normal file
@@ -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 ["<html>Sign in</html>", { 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user