Implemented the customer service requests view

This commit is contained in:
2026-09-22 17:55:38 -03:00
parent 3ee8eb6f21
commit ea164cdcd6
24 changed files with 1158 additions and 20 deletions

View File

@@ -27,6 +27,7 @@ test("request categories cover every current family and unknown requests stay vi
requestConsumerTokenService: "authentication", requestCustomerTokenService: "authentication",
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "consumption",
requestConsumptionServiceWeekly: "consumption", requestConsumptionServiceDaily: "consumption", futureRequest: "other"
})) assert.equal(h.app.debugContentView.getRequestCategory({ name }), category, name);
@@ -93,7 +94,7 @@ test("tabs support arrows, Home and End with a single selected tab and labelled
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
const root = path.join(__dirname, "..");
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css"]
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));

View File

@@ -62,6 +62,7 @@ test("ten columns retain their links and put product/customer controls below the
assert.ok(customer.children[0].href.endsWith("party%2Fid"));
assert.equal(customer.children[1].children[0].title, "View customer consumption");
assert.equal(customer.children[1].children[1].title, "View customer installed base");
assert.equal(customer.children[1].children[2].title, "View customer service requests");
assert.equal(findClass(h.cell("optyNumber"), "opportunities-extension-copy-button").title, "Copy opportunity number");
assert.equal(findClass(h.cell("name"), "opportunities-extension-action-button"), undefined);
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
@@ -146,6 +147,8 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.match(actions, /position: absolute/);
assert.match(actions, /bottom: 10px/);
assert.match(actions, /height: 24px/);
assert.match(css, /min-width: 84px/);
assert.match(css, /flex: 0 0 24px/);
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
assert.doesNotMatch(css, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
@@ -153,7 +156,8 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
const remaining = width - fixed;
assert.equal(fixed + remaining / 2 + remaining / 2, width);
assert.ok(remaining > 0);
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
}
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css"].map(read).join("\n");
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});

View File

@@ -0,0 +1,222 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response, plain } = require("./support/characterization.cjs");
const { loadApplication } = require("./support/application.cjs");
const fs = require("node:fs");
const path = require("node:path");
const authorizations = {
accountSummary: { name: "Account-Ti-Authorization", value: "synthetic-summary-token" },
customerServiceRequests: { name: "lead-auth", value: "synthetic-sr-token" }
};
const message = { type: "opportunitiesExtension.requestCustomerServiceRequest", partyNumber: "customer-test", authorizations };
const tokens = [
{ tokenService: "ACCP-TS", uiaasHeader: { label: "Account-Ti-Authorization", value: "synthetic-summary-token" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption-token" } },
{ tokenService: "SAIB-TS", uiaasHeader: { value: "synthetic-install-token" } },
{ tokenService: "SPA-ORANGE", uiaasHeader: { label: "lead-auth", value: "synthetic-sr-token" } }
];
for (const family of ["chrome", "firefox"]) {
test(`${family}: SPA-ORANGE selection, summary id (not registryId), GET and pagination`, async () => {
const calls = [];
const h = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
if (url.endsWith("consumerTokenService")) return response(tokens);
if (url.endsWith("accountsummary")) return response({ id: "account/id", registryId: "WRONG-ID" });
const offset = Number(new URL(url).searchParams.get("offset"));
const count = offset === 0 ? 40 : 1;
return response({ totalResults: 41, offset, count, limit: 40,
objects: Array.from({ length: count }, (_, i) => ({ id: `SR-${offset + i}` })) });
} });
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
assert.deepEqual(auth.authorizations.customerServiceRequests, { ...authorizations.customerServiceRequests, expires: "" });
const result = await h.dispatch({ ...message, authorizations: auth.authorizations });
assert.equal(result.ok, true);
assert.equal(result.items.length, 41);
assert.equal(calls[1].options.method, "POST");
assert.equal(calls[1].options.headers[authorizations.accountSummary.name], authorizations.accountSummary.value);
assert.deepEqual(JSON.parse(calls[1].options.body), [{ key: "regId", value: "customer-test" }]);
for (const [index, call] of calls.slice(2).entries()) {
const url = new URL(call.url);
assert.equal(url.pathname, "/oalcrm/web/api/provider-proxy/g2m-custsr-provider/service/oscSr/account%2Fid");
assert.deepEqual(JSON.parse(url.searchParams.get("customerSRFilter")), { hierarchy: "single", status: "open" });
assert.equal(url.searchParams.get("orderBy"), "creationDate");
assert.equal(url.searchParams.get("orderByMode"), "desc");
assert.equal(url.searchParams.get("limit"), "40");
assert.equal(url.searchParams.get("offset"), String(index * 40));
assert.equal(call.options.method, "GET");
assert.equal(call.options.body, undefined);
assert.equal(call.options.credentials, "include");
assert.deepEqual(plain(call.options.headers), {
Accept: "application/json", "custsr-auth": authorizations.customerServiceRequests.value
}, "send the raw SPA-ORANGE value in custsr-auth, never lead-auth or Authorization/Bearer");
}
assert.deepEqual(result.debugRequests.map(entry => entry.name), ["requestAccountSummary", "requestCustomerServiceRequest", "requestCustomerServiceRequest"]);
assert.doesNotMatch(JSON.stringify(result.debugRequests), /synthetic-(sr|summary)-token/);
assert.ok(result.debugRequests.every(entry => entry.httpStatus === 200));
assert.equal(result.debugRequests[1].requestHeaders["custsr-auth"], "(present)");
});
}
test("SR authentication uses SPA-ORANGE value even when its header label is absent", async () => {
let srCalls = 0;
const h = loadBackground({ fetch: async (url, options) => {
if (url.endsWith("consumerTokenService")) return response(tokens.map(token => token.tokenService === "SPA-ORANGE"
? { tokenService: "SPA-ORANGE", uiaasHeader: { value: "synthetic-sr-token" } } : token));
if (url.endsWith("accountsummary")) return response({ id: "account-test" });
srCalls += 1;
assert.deepEqual(plain(options.headers), { Accept: "application/json", "custsr-auth": "synthetic-sr-token" });
return response({ totalResults: 0, offset: 0, count: 0, objects: [] });
} });
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
const result = await h.dispatch({ ...message, authorizations: auth.authorizations });
assert.equal(result.ok, true);
assert.equal(srCalls, 1);
});
test("zero and exact page-size totals stop without an extra request", async () => {
for (const total of [0, 40]) {
let calls = 0;
const h = loadBackground({ fetch: async url => {
calls += 1;
if (url.endsWith("accountsummary")) return response([{ id: "summary-id" }]);
return response({ totalResults: total, offset: 0, count: total, objects: Array.from({ length: total }, (_, i) => ({ id: `SR-${i}` })) });
} });
const result = await h.dispatch(message);
assert.equal(result.ok, true);
assert.equal(result.items.length, total);
assert.equal(calls, 2);
}
});
test("missing SPA-ORANGE stays optional for existing features and fails only the new action", async () => {
const h = loadBackground({ fetch: async () => response(tokens.filter(token => token.tokenService !== "SPA-ORANGE")) });
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
assert.equal(auth.ok, true);
const result = await h.dispatch({ ...message, authorizations: auth.authorizations });
assert.equal(result.ok, false);
assert.match(result.error, /SPA-ORANGE/);
assert.equal(result.debugRequests.length, 0);
});
test("missing identifiers, stalled pages, malformed collections, network and HTTP failures are visible", async () => {
for (const kind of ["id", "stalled", "offset", "count", "total", "objects", "http", "json", "network"]) {
const h = loadBackground({ fetch: async url => {
if (url.endsWith("accountsummary")) return response(kind === "id" ? { registryId: "wrong" } : { id: "account-id" });
if (kind === "http") return response({}, 401);
if (kind === "json") return new Response("<html>fallback</html>");
if (kind === "network") throw new Error("Synthetic network failure");
const page = { totalResults: 1, offset: 0, count: 0, objects: [] };
if (kind === "offset") page.offset = 40;
if (kind === "count") page.count = 1;
if (kind === "total") delete page.totalResults;
if (kind === "objects") page.objects = null;
return response(page);
} });
const result = await h.dispatch(message);
assert.equal(result.ok, false, kind);
assert.ok(result.error, kind);
assert.equal(result.debugRequests.length, kind === "id" ? 1 : 2);
assert.doesNotMatch(JSON.stringify(result.debugRequests), /synthetic-(sr|summary)-token/);
}
});
class Element {
constructor(tag, document) { this.tag = tag; this.document = document; this.children = []; this.dataset = {}; this.attributes = {}; this.listeners = {}; }
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text || ""; }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { this.document.activeElement = this; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(node => node !== this); this.parent = null; }
get isConnected() { return Boolean(this.parent); }
click() { return this.listeners.click(); }
}
const walk = node => [node, ...node.children.flatMap(walk)];
function setup() {
const document = { createElement: tag => new Element(tag, document), createElementNS: (_, tag) => new Element(tag, document),
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
const overlay = new Element("main", document);
const app = loadApplication("content", { document, Intl, Date });
return { app, overlay, document };
}
const customer = { CustomerAccount: { PartyNumber: "customer-test", PartyUniqueName: "Synthetic customer" } };
test("skeleton, exact field mapping, safe text, dates, badge, empty/error and focus states", () => {
const { app, overlay, document } = setup();
const trigger = new Element("button", document);
overlay.append(trigger);
const view = app.serviceRequestsView.openServiceRequestsModal(customer, trigger);
const modal = overlay.children[1];
assert.equal(modal.attributes["aria-busy"], "true");
assert.equal(walk(modal).filter(node => node.tag === "td").length, 88);
assert.deepEqual(walk(modal).filter(node => node.tag === "th").map(node => node.textContent), [
"Name", "Registry ID", "SR Number", "Severity", "Status", "Substatus", "Product Name", "OS", "CSI", "Creation date", "Modified"
]);
view.render([{ customer: { name: "<script>not HTML</script>", registryId: "reg-1" }, id: "SR-1", severity: "3", status: "Open",
subStatus: "Work In Progress", component: { productName: null }, os: ": ", csi: "Unspecified", created: "2026-03-04", lastUpdated: "2026-09-17" }]);
assert.equal(modal.attributes["aria-busy"], "false");
const cells = walk(modal).filter(node => node.tag === "td");
assert.equal(cells.length, 11);
assert.equal(cells[0].textContent, "<script>not HTML</script>");
assert.equal(cells[0].children.length, 0);
assert.equal(cells[1].textContent, "reg-1");
assert.equal(cells[2].textContent, "SR-1");
assert.equal(cells[4].children[0].dataset.status, "OPEN");
assert.equal(cells[5].textContent, "Work In Progress");
assert.equal(cells[6].textContent, "-");
assert.equal(cells[9].textContent, app.opportunitiesFormatters.formatOracleResponseDate("2026-03-04"));
assert.equal(cells[10].textContent, app.opportunitiesFormatters.formatOracleResponseDate("2026-09-17"));
modal.listeners.keydown({ key: "Tab", preventDefault() {} });
assert.equal(document.activeElement.attributes.role, "region");
view.render([]);
assert.ok(walk(modal).some(node => node.textContent === "No open service requests found for this customer."));
view.render([], "Synthetic failure");
assert.ok(walk(modal).some(node => node.attributes.role === "alert" && node.textContent === "Synthetic failure"));
assert.equal(app.serviceRequestsView.closeServiceRequestsModal(), true);
assert.equal(document.activeElement, trigger);
view.render([{ id: "Late" }]);
assert.equal(overlay.children.length, 1);
});
test("rapid clicks share the query; different customers and late responses stay isolated", async () => {
const { app, overlay } = setup();
const requests = [];
const logs = [];
app.authContentSession.requestConsumerTokenServiceOnce = async () => authorizations;
app.platformContentMessages.sendRuntimeMessage = request => new Promise(resolve => requests.push({ request, resolve }));
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(entries);
const button = app.serviceRequestsAction.createServiceRequestsButton(customer);
const first = button.click();
assert.equal(overlay.children[0].attributes["aria-busy"], "true");
const repeated = button.click();
const otherButton = app.serviceRequestsAction.createServiceRequestsButton({ CustomerAccount: { PartyNumber: "other-customer" } });
const second = otherButton.click();
await new Promise(resolve => setImmediate(resolve));
assert.equal(requests.length, 2);
assert.deepEqual(plain(requests[0].request), message);
requests[1].resolve({ ok: true, items: [{ id: "Other SR" }], debugRequests: [] });
await second;
requests[0].resolve({ ok: true, items: [{ id: "Wrong customer" }], debugRequests: [] });
await Promise.all([first, repeated]);
assert.ok(walk(overlay.children[0]).some(node => node.textContent === "Other SR"));
assert.equal(walk(overlay.children[0]).some(node => node.textContent === "Wrong customer"), false);
assert.equal(logs.length, 2);
const retry = button.click();
await new Promise(resolve => setImmediate(resolve));
assert.equal(requests.length, 3, "results are not cached after completion");
requests[2].resolve({ ok: false, error: "Synthetic request failure", debugRequests: [] });
await retry;
assert.ok(walk(overlay.children[0]).some(node => node.textContent === "Synthetic request failure"));
});
test("styles allocate 100 percent, support dark mode and reduced motion, without hiding values", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/service-requests/styles.css"), "utf8");
const overrides = [...css.matchAll(/\.service-requests-col-\w+ \{ width: (\d+)%; \}/g)].map(match => Number(match[1]));
assert.equal(overrides.reduce((a, b) => a + b, 0) + (11 - overrides.length) * 7, 100);
assert.match(css, /min-width: 1200px/);
assert.match(css, /data-theme="dark"/);
assert.match(css, /prefers-reduced-motion/);
assert.doesNotMatch(css, /text-overflow: ellipsis/);
});