Updated the process for retrieving records from CRMServiceRequests

This commit is contained in:
2026-10-28 11:43:26 -03:00
parent 318459cef8
commit 3fa212e561
17 changed files with 796 additions and 328 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,7 @@ module.exports = {
"src/features/opportunities/dashboard-state.js",
"src/features/debug/state.js",
"src/features/opportunities/columns.js",
"src/features/opportunities/print.js",
"src/features/opportunities/badge-content.js",
"src/content/fusion-page.js",
"src/content/modal.js",

View File

@@ -296,8 +296,8 @@ function createContentApplication(overrides = {}) {
});
const internalSrSession = createInternalSrSession({
environment,
query: async srId => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
query: async (srId, srNumber) => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId, srNumber });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
return result;
}
@@ -305,7 +305,7 @@ function createContentApplication(overrides = {}) {
const crmSrPopover = createCrmSrPopover({
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestDetails: srId => internalSrSession.request(srId),
requestDetails: (srId, srNumber) => internalSrSession.request(srId, srNumber),
MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
@@ -364,7 +364,43 @@ function createContentApplication(overrides = {}) {
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
});
const opportunitiesPrint = createOpportunitiesPrint({ environment, getSnapshot: () => {
const state = opportunitiesTableState.opportunitiesTableState;
const items = opportunitiesFilters.getSortedOpportunities();
const filter = opportunitiesFilterState;
const selectedLabels = (options, selected, allLabel) => {
const matching = options.filter(option => selected.has(option.key));
if (options.length && matching.length === options.length) return allLabel;
return matching.map(option => option.label).join(", ") || "None";
};
const root = environment.document.getElementById(contracts.MODAL_ID);
const filters = [
["Reference", root?.querySelector(".opportunities-extension-form select")?.value || "-"],
["Customer", selectedLabels(opportunitiesFormatters.getCustomerOptions(), filter.selectedCustomerKeys, "All customers")],
["Owner", selectedLabels(opportunitiesFormatters.getOwnerOptions(), filter.selectedOwnerKeys, "All owners")],
["Search", filter.tableSearch || "All"],
["Status", [...filter.selectedStatuses].join(", ")],
["Stage", [...filter.selectedStages].join(", ")],
["Opportunity type", [...filter.selectedProductTypes].join(", ")]
];
for (const group of root?.querySelectorAll(".opportunities-extension-activity-filter-controls") || []) {
filters.push([group.getAttribute("aria-label").replace(/^Filter by /, ""),
[...group.querySelectorAll('[aria-pressed="true"]')].map(button => button.getAttribute("aria-label")).join("; ")]);
}
const totals = [{ label: "Total Opportunities", value: `${items.length}/${state.items.length}`, detail: "Filtered / total" }];
for (const stage of contracts.STAGE_OPTIONS) {
const stageItems = items.filter(item => item.ForecastGroup_c === stage);
const amount = stageItems.reduce((total, item) => {
const value = Number(item.PrimaryRevenue?.RevnAmount);
return total + (Number.isFinite(value) ? value : 0);
}, 0);
totals.push({ label: stage, value: opportunitiesDashboard.createStageAmountFormatter(stageItems)(amount), detail: `${stageItems.length} opportunities` });
}
return { status: state.status, filters, totals, columns,
rows: items.map(item => columns.map(column => opportunitiesFormatters.displayOpportunityValue(column, item))) };
} });
const opportunityCalendar = createOpportunityCalendar({
createPrintButton: () => opportunitiesPrint.createButton(),
productTypeSelection: opportunitiesFilterState.selectedProductTypes,
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
createViewIcon: view => contentIcons.createViewIcon(view),

View File

@@ -78,7 +78,18 @@
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort-icon { order: -1; margin-left: 0; margin-right: auto; }
.opportunities-extension-modal table[data-table-layout="standard"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; }
/* Preserve room for Overview badges and the compact daily entry controls. */
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 38px; vertical-align: top; }
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 10px; vertical-align: top; }
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content > .opportunities-extension-cell-actions {
position: static;
height: 24px;
flex-shrink: 0;
}
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-left: 5px; padding-right: 5px; }
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }

View File

@@ -27,7 +27,7 @@ function createDebugContentView({
if (entry.name === "requestOpptyProducts") return "products";
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
if (entry.name === "requestInstallBase") return "installBase";
if (["requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
if (["requestSRSearchResources", "requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other";

View File

@@ -1,7 +1,7 @@
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
productTypeSelection, onProductTypeChange }) {
productTypeSelection, onProductTypeChange, createPrintButton }) {
const { document } = environment;
let elements = null;
let calendar = null;
@@ -223,6 +223,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
});
filters.append(button);
}
if (createPrintButton) filters.append(createPrintButton());
viewbar.append(filters);
}
workspace.append(viewbar, tableSurface, surface); body.append(workspace);

View File

@@ -124,9 +124,13 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
children.setAttribute("role", "status");
children.textContent = "Loading SR details...";
section.append(children);
detailLoads.push(async () => {
const loadDetails = async () => {
if (active !== entry || entry.panel !== panel) return;
children.textContent = "Loading SR details...";
children.setAttribute("aria-busy", "true");
children.setAttribute("role", "status");
try {
const records = await requestDetails(item.SrId);
const records = await requestDetails(item.SrId, item.SrNumber);
if (active !== entry || entry.panel !== panel) return;
children.textContent = "";
if (!records.length) children.textContent = "No further details.";
@@ -157,13 +161,20 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
if (active !== entry || entry.panel !== panel) return;
children.setAttribute("role", "alert");
children.textContent = error.message || "Unable to load SR details.";
const retry = document.createElement("button");
retry.type = "button";
retry.textContent = "Retry";
retry.setAttribute("aria-label", "Retry loading Internal Service Requests");
retry.addEventListener("click", () => { retry.disabled = true; loadDetails(); });
children.append(retry);
} finally {
if (active === entry && entry.panel === panel) {
children.setAttribute("aria-busy", "false");
entry.position();
}
}
});
};
detailLoads.push(loadDetails);
panel.append(section);
});
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));

View File

@@ -1,16 +1,15 @@
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
const { document, setTimeout } = environment;
const pending = new Map();
let recovery = null;
const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
const now = () => environment.performance?.now() ?? Date.now();
function isUnauthorized(result) {
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
if (status != null) return Number(status) === 401;
// Older background workers may return only the request error text.
return /failed with status 401\.$/.test(result?.error || "");
return status != null ? Number(status) === 401 : /failed with status 401\.$/.test(result?.error || "");
}
async function warmSession(id) {
async function warmSession(id, srNumber) {
const document = environment?.document;
if (!document?.body) throw new Error(`${sessionLabel} session cannot be initialized on this page.`);
const frame = document.createElement("iframe");
// Restore the original session initialization route without opening a tab.
frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr";
frame.title = `${sessionLabel} session initialization`;
frame.tabIndex = -1;
@@ -18,43 +17,46 @@ function createInternalSrSession({ query, environment, sessionUrl, sessionLabel
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;";
let blocked = false;
let loaded = false;
frame.addEventListener("error", () => { blocked = true; });
frame.addEventListener("load", () => { loaded = true; });
const failed = () => { blocked = true; };
frame.addEventListener("error", failed);
try {
if (!document.body) throw new Error(`The current page cannot initialize the ${sessionLabel} session.`);
document.body.append(frame);
const deadline = now() + 60000;
for (let attempt = 0; now() < deadline; attempt += 1) {
// Check immediately, then briefly poll faster before backing off.
if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now()));
if (now() >= deadline) break;
if (blocked) throw new Error(`The ${sessionLabel} session iframe could not be loaded. Sign in manually and try again.`);
const result = await query(id);
if (result?.ok) return { id, result };
// Keep the iframe alive while its application completes authentication.
// A load event is not proof of a ready session. Limit and space API checks.
for (const delay of [2000, 4000, 8000, 12000, 16000]) {
await new Promise(resolve => environment.setTimeout(resolve, delay));
if (blocked) throw new Error(`${sessionLabel} session iframe could not be loaded. Retry after signing in.`);
const result = await query(id, srNumber);
if (result?.ok) return { id: String(id), result };
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
}
throw new Error(`${sessionLabel} session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`);
throw new Error(`${sessionLabel} session is still unavailable. Retry loading the details after signing in.`);
} finally {
// Stop remaining page resources without stopping the caller's page.
try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ }
frame.src = "about:blank";
frame.removeEventListener("error", failed);
try { frame.contentWindow?.stop(); } catch { /* SSO may redirect cross-origin. */ }
frame.remove();
}
}
async function request(id) {
let result = await query(id);
for (let attempt = 0; !result?.ok && isUnauthorized(result) && attempt < 2; attempt += 1) {
function request(id, srNumber) {
const key = String(id);
if (!pending.has(key)) {
const operation = (async () => {
let result = await query(id, srNumber);
if (!result?.ok && isUnauthorized(result)) {
if (!recovery) {
recovery = warmSession(id);
const pending = recovery;
pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {});
const current = warmSession(id, srNumber);
recovery = current;
current.finally(() => { if (recovery === current) recovery = null; }).catch(() => {});
}
const recovered = await recovery;
result = recovered.id === id ? recovered.result : await query(id);
result = recovered.id === key ? recovered.result : await query(id, srNumber);
}
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
return result.items;
})().finally(() => pending.delete(key));
pending.set(key, operation);
}
return pending.get(key);
}
return { request };
}

View File

@@ -0,0 +1,100 @@
function createOpportunitiesPrint({ environment, getSnapshot }) {
function createButton() {
const doc = environment.document;
const button = doc.createElement("button");
button.type = "button";
button.className = "opportunities-extension-icon-button";
button.title = "Print opportunities";
button.setAttribute("aria-label", "Print opportunities");
const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.6");
svg.setAttribute("aria-hidden", "true");
const path = doc.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6zM17 11h1");
svg.append(path); button.append(svg);
button.addEventListener("click", print);
return button;
}
function print() {
const snapshot = getSnapshot();
if (!["ready", "empty"].includes(snapshot.status)) {
environment.window.alert("Wait for opportunities to finish loading before printing.");
return;
}
const popup = environment.window.open("", "_blank", "width=1200,height=850");
if (!popup) { environment.window.alert("Allow pop-ups to print opportunities."); return; }
popup.opener = null;
const doc = popup.document;
doc.title = "Opportunities Extension — Report";
doc.documentElement.lang = "en";
const node = (tag, text, parent) => {
const el = doc.createElement(tag);
if (text != null) el.textContent = String(text);
if (parent) parent.append(el);
return el;
};
node("style", `
@page { size: A4 landscape; margin: 15mm; }
* { box-sizing: border-box; }
body { margin: 32px; font: 11px/1.4 Arial, sans-serif; color: #222; background: white; }
h1 { margin: 0 0 4px; font-size: 21px; } h2 { font-size: 13px; margin: 16px 0 6px; }
.meta { color: #555; margin: 0 0 12px; }
.filters { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px 16px; border: 1px solid #ccc; padding: 10px; }
.filters div { overflow-wrap: anywhere; } .filters strong { margin-right: 5px; }
.totals { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 12px 0; }
.totals section { border: 1px solid #bbb; border-top: 3px solid #426a75; padding: 8px; break-inside: avoid; }
.totals strong { display: block; font-size: 16px; } .totals small { display: block; }
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 9px; }
th, td { padding: 6px 4px; border-bottom: 1px solid #ccc; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
th { background: #eee; font-weight: bold; } tbody tr:nth-child(even) { background: #f7f7f7; }
thead { display: table-header-group; } tr { break-inside: avoid; }
th:nth-child(1), th:nth-child(3) { width: 22%; }
th:nth-child(2) { width: 7%; } th:nth-child(4) { width: 8%; }
th:nth-child(5) { width: 9%; } th:nth-child(6) { width: 7%; }
th:nth-child(7), th:nth-child(8) { width: 5%; } th:nth-child(9) { width: 7%; } th:nth-child(10) { width: 8%; }
.money { text-align: right; font-variant-numeric: tabular-nums; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
button { padding: 8px 14px; cursor: pointer; }
@media print { body { margin: 0; } .toolbar { display: none; } th { print-color-adjust: exact; } }
`, doc.head);
const toolbar = node("div", null, doc.body); toolbar.className = "toolbar";
node("button", "Print / Save PDF", toolbar).addEventListener("click", () => popup.print());
node("button", "Close", toolbar).addEventListener("click", () => popup.close());
node("h1", "Opportunities Extension", doc.body);
node("p", `Generated ${new Date().toLocaleString("en-US")} • ${snapshot.rows.length} opportunities`, doc.body).className = "meta";
const filters = node("div", null, doc.body); filters.className = "filters";
for (const [label, value] of snapshot.filters) {
const field = node("div", null, filters);
node("strong", `${label}:`, field); node("span", value || "None", field);
}
const totals = node("div", null, doc.body); totals.className = "totals";
for (const card of snapshot.totals) {
const section = node("section", null, totals);
node("span", card.label, section); node("strong", card.value, section);
if (card.detail) node("small", card.detail, section);
}
node("h2", "Opportunities", doc.body);
const table = node("table", null, doc.body);
const header = node("tr", null, node("thead", null, table));
for (const column of snapshot.columns) {
const th = node("th", column.label, header); th.scope = "col";
if (column.key === "revenue") th.className = "money";
}
const body = node("tbody", null, table);
for (const values of snapshot.rows) {
const row = node("tr", null, body);
values.forEach((value, index) => {
const td = node("td", value, row);
if (snapshot.columns[index].key === "revenue") td.className = "money";
});
}
if (!snapshot.rows.length) node("p", "No opportunities match the selected filters.", doc.body);
popup.focus();
// Print a frozen snapshot, without changing the extension's scroll or selected view.
environment.setTimeout(() => { if (!popup.closed) popup.print(); }, 150);
}
return { createButton, print };
}

View File

@@ -2,10 +2,27 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
logRequestStart, logRequestSuccess, logRequestFailure, readResponseBodyForDebug, maskToken, environment }) {
const { fetch } = environment;
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries";
function createPayload(offset) {
async function requestSRSearchResources(token) {
const searchUrl = `${url}?applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=CRMServiceRequest&language=en&limit=-1&onlyData=true`;
const headers = { Accept: "application/json", Authorization: `Bearer ${token}` };
const entry = logRequestStart({ name: "requestSRSearchResources", method: "GET", url: searchUrl, headers });
try {
const response = await fetch(searchUrl, { method: "GET", credentials: "include", headers });
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (response.status !== 200) throw new Error(`requestSRSearchResources failed with status ${response.status}.`);
const data = await response.json();
const number = data?.items?.[0]?.savedSearchNumber;
if (!Array.isArray(data?.items) || typeof number !== "string" || !number.trim()) {
throw new Error("requestSRSearchResources did not return savedSearchNumber in its first item.");
}
return number.trim();
} catch (error) { logRequestFailure(entry, error.message); throw error; }
}
function createPayload(offset, savedSearchNumber) {
if (typeof savedSearchNumber !== "string" || !savedSearchNumber.trim()) throw new Error("Missing SR savedSearchNumber.");
return {
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true,
entity: "CRMServiceRequest", copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205",
entity: "CRMServiceRequest", copiedFrom: `queries/${savedSearchNumber.trim()}`,
q: { op: "$and", criteria: [
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
@@ -19,11 +36,13 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
};
}
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
if (!isActive()) return null;
const savedSearchNumber = await requestSRSearchResources(token);
const items = [];
let offset = 0;
let attempt = 1;
while (isActive()) {
const payload = createPayload(offset);
const payload = createPayload(offset, savedSearchNumber);
const headers = {
Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`,
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
@@ -60,5 +79,5 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
}
return null;
}
return { createPayload, requestCrmServiceRequest };
return { createPayload, requestCrmServiceRequest, requestSRSearchResources };
}

View File

@@ -4,7 +4,8 @@ const { loadApplication } = require("./support/application.cjs");
const { response } = require("./support/characterization.cjs");
function setup(fetch) {
const app = loadApplication("content", { fetch });
const app = loadApplication("content", { fetch: (url, options) => options.method === "GET"
? response({ items: [{ savedSearchNumber: "dynamic-sr-search" }] }) : fetch(url, options) });
app.currentUser.allowsCrmActivities = () => true;
app.opportunitiesQueryState.periodRequestVersion = 1;
app.debugContentView.renderDebugPanel = () => {};

View File

@@ -2,10 +2,10 @@ const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const response = data => new Response(JSON.stringify(data), { status: 200 });
function setup(fetch) {
function setup(fetch, searchFetch = async () => response({ items: [{ savedSearchNumber: "dynamic-sr-search" }] })) {
const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
document.createElementNS = (_, tag) => document.createElement(tag);
const app = loadApplication("content", { fetch, document, Intl, Date });
const app = loadApplication("content", { fetch: (url, options) => options.method === "GET" ? searchFetch(url, options) : fetch(url, options), document, Intl, Date });
app.currentUser.allowsCrmActivities = () => true;
app.debugContentView.renderDebugPanel = () => {};
app.opportunitiesQueryState.periodRequestVersion = 1;
@@ -15,6 +15,42 @@ const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
test("SR saved search runs once before pages and uses the first item, not the default", async () => {
const calls = [];
const app = setup(async (_url, options) => {
const payload = JSON.parse(options.body); calls.push(payload);
assert.equal(payload.copiedFrom, "queries/first-search");
return response({ items: [sr("A")], count: 1, offset: payload.offset, hasMore: payload.offset === 0 });
}, async (url, options) => {
assert.equal(options.method, "GET");
assert.equal(options.headers.Authorization, "Bearer synthetic");
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries?applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=CRMServiceRequest&language=en&limit=-1&onlyData=true");
calls.push("lookup");
return response({ items: [{ savedSearchNumber: "first-search" }, { savedSearchNumber: "other", isDefault: true }] });
});
await app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1);
assert.equal(calls.length, 3); assert.equal(calls[0], "lookup");
assert.equal(app.debugContentView.getRequestCategory(app.debugState.requestLog[0]), "serviceRequests");
});
test("failed or empty SR search blocks POST; superseded search never starts pagination", async () => {
for (const lookup of [
async () => new Response("Denied", { status: 401 }),
async () => response({ items: [] }),
async () => response({ items: [{ savedSearchNumber: " " }] }),
async () => new Response("<html>Login</html>")
]) {
const app = setup(() => assert.fail("No POST without a saved search"), lookup);
await assert.rejects(app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1));
assert.equal(app.debugState.requestLog[0].status, "failed");
}
const app = setup(() => assert.fail("Stale POST"), async () => {
app.opportunitiesQueryState.periodRequestVersion = 2;
return response({ items: [{ savedSearchNumber: "first" }] });
});
assert.equal(await app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1), null);
});
test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => {
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/A"], [[4, null], "N/A"], [[""], "N/A"], [["invalid"], "N/A"]]) {
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
@@ -79,7 +115,7 @@ test("CRM query preserves payload and pagination after opportunities, uses token
assert.equal(options.headers.Preference, "transient");
assert.deepEqual(payload, {
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "CRMServiceRequest",
copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205",
copiedFrom: "queries/dynamic-sr-search",
q: { op: "$and", criteria: [
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
@@ -90,8 +126,9 @@ test("CRM query preserves payload and pagination after opportunities, uses token
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"],
skipInValidFields: true, limit: 15, offset: 0
});
assert.equal(app.debugState.requestLog.length, 2);
for (const log of app.debugState.requestLog) {
assert.equal(app.debugState.requestLog.length, 3);
assert.equal(app.debugState.requestLog[0].name, "requestSRSearchResources");
for (const log of app.debugState.requestLog.slice(1)) {
assert.equal(log.name, "resquestCrmServiceRequest");
assert.equal(log.requestHeaders.Origin, "https://eeho.fa.us2.oraclecloud.com");
assert.equal(log.requestHeaders.Preference, "transient");
@@ -144,6 +181,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
let resolveOld;
const app = setup(() => new Promise(resolve => { resolveOld = resolve; }));
const old = app.opportunityCrmServiceRequests.load("token", 1);
while (!resolveOld) await new Promise(resolve => setImmediate(resolve));
app.opportunitiesQueryState.periodRequestVersion = 2;
app.opportunityCrmServiceRequests.reset();
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [sr("NEW")];
@@ -152,7 +190,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
await old;
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
assert.equal(app.debugState.requestLog.length, 1);
assert.equal(app.debugState.requestLog.length, 2);
});
test("Reference changes share pending pagination and reuse completed CRM results", async () => {
@@ -167,6 +205,7 @@ test("Reference changes share pending pagination and reuse completed CRM results
app.opportunitiesQueryState.periodRequestVersion = 2;
const second = app.opportunityCrmServiceRequests.load("token", 2);
assert.equal(first, second);
while (!finish) await new Promise(resolve => setImmediate(resolve));
finish(response({ items: [sr("A")], count: 1, offset: 0, hasMore: true }));
await second;
app.opportunitiesQueryState.periodRequestVersion = 3;

View File

@@ -250,44 +250,6 @@ test("Primary pillar lookup uses Session, orders enabled name/code options and r
assert.equal((await loadBackground({ fetch, messageTimeout: 10000 }).dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" })).ok, false);
}
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: 401 is returned to the caller without opening tabs`, async () => {
let count = 0;
const h = loadBackground({ family, fetch: async () => ++count < 3 ? response({}, 401) : response({ items: [] }) });
const result = await h.dispatch({ type, srId: "123" });
assert.equal(result.ok, false);
assert.equal(result.httpStatus, 401);
assert.equal(count, 1);
assert.equal(h.tabs.length, 0);
assert.deepEqual(h.removedTabs, []);
assert.deepEqual(result.debugRequests.map(entry => entry.httpStatus), [401]);
});
}
test("concurrent 401 responses do not cause background tab recovery", async () => {
let calls = 0;
const h = loadBackground({ fetch: async url => {
if (++calls <= 2) return response({}, 401);
const id = new URL(url).searchParams.get("q");
return response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: [{ Title: id }] }] });
} });
const results = await Promise.all([h.dispatch({ type, srId: "1" }), h.dispatch({ type, srId: "2" })]);
assert.equal(h.tabs.length, 0);
assert.deepEqual(h.removedTabs, []);
assert.ok(results.every(result => result.httpStatus === 401));
});
test("background never creates recovery tabs after an error", async () => {
for (const status of [401, 500]) {
let count = 0;
const h = loadBackground({ fetch: async () => response({}, ++count === 1 ? 401 : status) });
const result = await h.dispatch({ type, srId: "1" });
assert.equal(result.ok, false);
assert.equal(count, 1);
assert.deepEqual(h.removedTabs, []);
}
const h = loadBackground({ fetch: async () => response({}, 403) });
assert.equal((await h.dispatch({ type, srId: "1" })).ok, false);
assert.equal(h.tabs.length, 0);
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
const calls = [];

View File

@@ -84,7 +84,7 @@ test("internal SR form validates Save, maps parent data, prevents duplicate post
assert.ok(!h.walk(h.modal).includes(form));
});
test("internal SR uses Session without TokenRelay and reports unavailable iframe host", async () => {
test("internal SR uses Session and offers manual retry after 401 without automatic probes", async () => {
const h = setup();
let renewals = 0;
const sent = [];
@@ -106,6 +106,11 @@ test("internal SR uses Session without TokenRelay and reports unavailable iframe
assert.equal(sent[0].token, undefined);
assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest"));
assert.ok(h.walk(h.panel()).some(node => node.attributes.role === "alert"));
const retry = h.walk(h.panel()).find(node => node.attributes["aria-label"] === "Retry loading Internal Service Requests");
assert.ok(retry);
retry.listeners.click();
await flush();
assert.equal(sent.length, 2);
});
function setup() {

View File

@@ -0,0 +1,14 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const fs = require('node:fs');
const vm = require('node:vm');
const factory = vm.runInNewContext(fs.readFileSync('src/services/fusion/sr-details.js', 'utf8') + '\ncreateFusionSrDetails');
test('Internal SR background returns 401 without opening any tab or retrying', async () => {
let calls = 0;
const api = factory({
fetchJson: async () => { calls++; throw Object.assign(new Error('Unauthorized'), {httpStatus:401}); },
tabsCreate: () => assert.fail('Internal SR must not open a tab')
});
await assert.rejects(api.requestInternalServiceRequest({srId:'123'}), /Unauthorized/);
assert.equal(calls,1);
});

View File

@@ -1,145 +1,69 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const vm = require("node:vm");
const path = require("node:path");
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/internal-sr-session.js"), "utf8")}\ncreateInternalSrSession`);
function setup(query, options = {}) {
let elapsed = 0;
const frames = [], removed = [], delays = [], stopped = [];
const body = { append(frame) { frame.initialSrc = frame.src; frames.push(frame); } };
const document = { body, createElement(tag) {
assert.equal(tag, "iframe");
return { contentWindow: { stop() { stopped.push(true); } }, style: {}, attributes: {}, listeners: {}, setAttribute(k, v) { this.attributes[k] = v; },
addEventListener(k, fn) { this.listeners[k] = fn; }, remove() { removed.push(this); } };
const assert = require('node:assert/strict');
const { test } = require('node:test');
const fs = require('node:fs');
const vm = require('node:vm');
const factory = vm.runInNewContext(fs.readFileSync('src/features/opportunities/internal-sr-session.js', 'utf8') + '\ncreateInternalSrSession');
test('without a document, session recovery stops without polling', async () => {
for (const result of [{ok:false,httpStatus:401}, {ok:false,debugRequests:[{httpStatus:401}]}, {ok:false,error:'Request failed with status 401.'}]) {
let calls=0;
const service=factory({query:async()=>{calls++; return result;},environment:{document:{createElement(){assert.fail('No iframe');}},setTimeout(){assert.fail('No polling');}}});
await assert.rejects(service.request('123'), /Internal SR|status 401/);
await new Promise(resolve=>setImmediate(resolve));
assert.equal(calls,1);
}
});
function harness() {
const timers = [], listeners = new Map();
let appended = 0, removed = 0;
const frame = {style:{},setAttribute(){},addEventListener(k,v){listeners.set(k,v);},removeEventListener(k){listeners.delete(k);},remove(){removed++;}};
return { timers, frame, listeners, counts:()=>({appended,removed}), environment:{
document:{createElement:()=>frame,body:{append(){appended++;}}},
setTimeout(fn,delay){timers.push({fn,delay});}
}};
const api = factory({ ...options, query, environment: { document, performance: { now: () => elapsed }, setTimeout: (fn, ms) => { delays.push(ms); elapsed += ms; return setImmediate(fn); } } });
return { api, frames, removed, delays, stopped };
}
test("lookup requests recover together using the configured page and preserve each result", async () => {
let authenticated = false;
let calls = 0;
const h = setup(async message => {
if (++calls <= 4) return { ok: false, httpStatus: 401 };
authenticated = true;
return { ok: true, items: [message.type] };
}, { sessionUrl: "https://eeho.fa.us2.oraclecloud.com/?view=Show%20Service%20Request", sessionLabel: "Service Request" });
const types = ["pillar", "language", "platform", "services"];
const results = await Promise.all(types.map(type => h.api.request({ type })));
assert.deepEqual(results, types.map(type => [type]));
assert.equal(authenticated, true);
assert.equal(h.frames.length, 1);
assert.match(h.frames[0].initialSrc, /Show%20Service%20Request$/);
assert.equal(h.frames[0].title, "Service Request session initialization");
assert.equal(h.removed.length, 1);
assert.equal(h.stopped.length, 1);
const flush = () => new Promise(resolve=>setImmediate(resolve));
test('hidden iframe remains alive after load and closes only after successful session query',async()=>{
const h=harness(); let calls=0;
const api=factory({environment:h.environment,query:async()=>++calls<3?{ok:false,httpStatus:401}:{ok:true,items:[{SrId:1}]}});
const first=api.request('123'); assert.equal(first,api.request(123));
await flush();
assert.equal(h.counts().appended,1);
h.listeners.get('load')?.();
assert.equal(h.counts().removed,0);
assert.equal(calls,1);
assert.equal(h.timers[0].delay,2000);
h.timers.shift().fn(); await flush();
assert.equal(h.counts().removed,0);
assert.equal(h.timers[0].delay,4000);
h.timers.shift().fn();
assert.equal((await first)[0].SrId,1);
assert.equal(h.counts().removed,1);
});
test("401 initializes a hidden iframe in the calling document and removes it on success", async () => {
let calls = 0;
const h = setup(async () => ++calls < 3 ? { ok: false, httpStatus: 401 } : { ok: true, items: [{ SrId: 1 }] });
const items = await h.api.request("1");
assert.equal(items[0].SrId, 1);
assert.equal(h.frames.length, 1);
const frame = h.frames[0];
assert.equal(frame.tabIndex, -1);
assert.equal(frame.attributes["aria-hidden"], "true");
assert.match(frame.style.cssText, /pointer-events:none/);
assert.ok(!frame.attributes.sandbox.includes("allow-top-navigation"));
assert.ok(frame.initialSrc.endsWith("&view=showisr"));
assert.equal(frame.src, "about:blank");
assert.equal(h.stopped.length, 1);
assert.deepEqual(h.delays, [250]);
assert.equal(h.removed[0], frame);
});
test("an immediately available session stops the iframe without waiting for load or a timer", async () => {
let calls = 0;
const h = setup(async () => ++calls === 1 ? { ok: false, httpStatus: 401 } : { ok: true, items: [] });
await h.api.request("1");
assert.equal(calls, 2);
assert.equal(h.delays.length, 0);
assert.equal(h.stopped.length, 1);
assert.equal(h.removed.length, 1);
});
test("lookup 401 errors from older background responses still initialize the session", async () => {
for (const failure of [
{ ok: false, error: "requestSrLookupLanguage failed with status 401." },
{ ok: false, debugRequests: [{ httpStatus: 401 }] },
{ ok: false, httpStatus: "401" }
]) {
let calls = 0;
const h = setup(async () => ++calls <= 2 ? failure : { ok: true, items: ["ready"] });
assert.deepEqual(await h.api.request("language"), ["ready"]);
assert.equal(h.frames.length, 1);
assert.equal(h.removed.length, 1);
test('persistent 401 is bounded to five recovery checks and removes iframe',async()=>{
const h=harness(); let calls=0;
const api=factory({environment:h.environment,query:async()=>{calls++;return {ok:false,httpStatus:401};}});
const failed=assert.rejects(api.request('123'),/still unavailable/);
for(const delay of [2000,4000,8000,12000,16000]) {
await flush(); assert.equal(h.timers[0].delay,delay); h.timers.shift().fn();
}
await failed;
assert.equal(calls,6);
assert.equal(h.counts().removed,1);
assert.equal(h.timers.length,0);
});
test("a lookup that still returns 401 after shared recovery initializes its own session", async () => {
const calls = new Map();
const h = setup(async id => {
const count = (calls.get(id) || 0) + 1;
calls.set(id, count);
return count <= (id === "language" ? 1 : 2)
? { ok: false, httpStatus: 401 } : { ok: true, items: [id] };
test('concurrent requests share work and explicit retry can recover', async () => {
let calls=0, complete;
const service=factory({query:()=>{calls++; return new Promise(resolve=>{complete=resolve;});}});
const first=service.request('123'); const second=service.request(123);
assert.equal(first,second); await Promise.resolve();
complete({ok:false,httpStatus:401}); await assert.rejects(first);
const retry=service.request('123'); await Promise.resolve(); complete({ok:true,items:[{SrId:'456'}]});
assert.equal((await retry)[0].SrId,'456'); assert.equal(calls,2);
});
assert.deepEqual(await Promise.all([h.api.request("language"), h.api.request("platform")]), [["language"], ["platform"]]);
assert.equal(h.frames.length, 2);
assert.equal(h.removed.length, 2);
});
test("cross-origin redirects still allow iframe removal when stop is blocked", async () => {
let calls = 0;
const h = setup(async () => {
if (++calls === 1) return { ok: false, httpStatus: 401 };
h.frames[0].contentWindow.stop = () => { throw new Error("Cross-origin"); };
return { ok: true, items: [] };
});
await h.api.request("1");
assert.equal(h.frames[0].src, "about:blank");
assert.equal(h.removed.length, 1);
});
test("concurrent queries share one iframe without mixing SR results", async () => {
let calls = 0;
const h = setup(async id => ++calls <= 2 ? { ok: false, httpStatus: 401 } : { ok: true, items: [id] });
const results = await Promise.all([h.api.request("1"), h.api.request("2")]);
assert.deepEqual(results, [["1"], ["2"]]);
assert.equal(h.frames.length, 1);
assert.equal(h.removed.length, 1);
});
test("normal success does not mount an iframe; errors and timeout always clean it up", async () => {
const success = setup(async () => ({ ok: true, items: [] }));
await success.api.request("1");
assert.equal(success.frames.length, 0);
for (const status of [401, 500]) {
let calls = 0;
const h = setup(async () => ({ ok: false, httpStatus: ++calls === 1 ? 401 : status }));
await assert.rejects(h.api.request("1"));
if (status === 401) {
assert.ok(calls > 31);
assert.equal(h.delays.reduce((sum, ms) => sum + ms, 0), 60000);
} else assert.equal(calls, 2);
assert.equal(h.removed.length, 1);
}
});
test("slow session initialization can succeed after the old thirty-attempt limit", async () => {
let calls = 0;
const h = setup(async () => ++calls < 45 ? { ok: false, httpStatus: 401 } : { ok: true, items: ["ready"] });
assert.deepEqual(await h.api.request("1"), ["ready"]);
assert.equal(calls, 45);
assert.equal(h.removed.length, 1);
assert.ok(h.delays.reduce((sum, ms) => sum + ms, 0) < 60000);
});
test("iframe load alone never confirms authentication", async () => {
let calls = 0;
const h = setup(async () => {
if (++calls > 1) h.frames[0].listeners.load();
return { ok: false, httpStatus: 401 };
});
await assert.rejects(h.api.request("1"), /HTTP 401.*load event, but authentication was not confirmed/);
assert.equal(h.removed.length, 1);
test('other errors do not retry and successful empty results are preserved', async()=>{
const failed=factory({query:async()=>({ok:false,error:'Forbidden',httpStatus:403})});
await assert.rejects(failed.request('123'),/Forbidden/);
const ok=factory({query:async()=>({ok:true,items:[]})}); assert.equal((await ok.request('123')).length,0);
});