Implemented the customer service requests view
This commit is contained in:
@@ -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"));
|
||||
|
||||
@@ -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)};`));
|
||||
});
|
||||
|
||||
222
tests/serviceRequests.test.cjs
Normal file
222
tests/serviceRequests.test.cjs
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user