346 lines
20 KiB
JavaScript
346 lines
20 KiB
JavaScript
const assert = require("node:assert/strict");
|
|
const { test } = require("node:test");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { loadBackground, response, plain } = require("./support/characterization.cjs");
|
|
const { loadApplication } = require("./support/application.cjs");
|
|
|
|
const type = "opportunitiesExtension.requestRatecardByPlan";
|
|
const item = {
|
|
orderNumber: "SYNTH-ORDER-2", partNum: "B-SYNTH-1", productName: "Synthetic GPU compute",
|
|
uom: "GPU PER HOUR", subLnStartSt: "30-Sep-2025", subLnEndDt: "27-May-2028",
|
|
usageMax: "-", rateCardDiscountPerc: "12.5", listPriceUsd: "4.3585",
|
|
netUnitPriceUsd: "0.9589", overagePriceUsd: "1.2501"
|
|
};
|
|
|
|
for (const family of ["chrome", "firefox"]) {
|
|
test(`${family}: rate card uses the active planNumber, OCICONS-TS and exact POST contract, with debug`, async () => {
|
|
const calls = [];
|
|
const app = loadBackground({ family, fetch: async (url, options) => {
|
|
calls.push({ url, options });
|
|
if (url.endsWith("consumerTokenService")) return response([
|
|
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } },
|
|
{ tokenService: "OCICONS-TS", uiaasHeader: { label: "not-used", value: "synthetic-consumption" } }
|
|
]);
|
|
return response([item]);
|
|
} });
|
|
const auth = await app.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
|
|
const result = await app.dispatch({ type, planNumber: "PLAN-123", subPlanNum: "WRONG-ID", authorization: auth.authorizations.accountPlans });
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(result.items, [item]);
|
|
assert.equal(result.planNumber, "PLAN-123");
|
|
assert.equal(calls[1].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/ratecard/byPlan");
|
|
assert.equal(calls[1].options.method, "POST");
|
|
assert.equal(calls[1].options.credentials, "include");
|
|
assert.equal(calls[1].options.cache, "no-store");
|
|
assert.deepEqual(JSON.parse(calls[1].options.body), { planNumber: "PLAN-123", recordsSize: 10000 });
|
|
assert.deepEqual(plain(calls[1].options.headers), {
|
|
Accept: "application/json", "Content-Type": "application/json", "G2m-Authorization": "synthetic-consumption"
|
|
});
|
|
const entry = result.debugRequests[0];
|
|
assert.equal(entry.name, "requestRatecardByPlan");
|
|
assert.equal(entry.httpStatus, 200);
|
|
assert.equal(entry.status, "success");
|
|
assert.ok(entry.startedAt && entry.completedAt);
|
|
assert.equal(typeof entry.durationMs, "number");
|
|
assert.deepEqual(JSON.parse(entry.requestBody), { planNumber: "PLAN-123", recordsSize: 10000 });
|
|
assert.match(entry.responseBody, /Synthetic GPU/);
|
|
const content = loadApplication("content");
|
|
assert.doesNotMatch(content.debugContentLog.formatHeaderBlock(entry.requestHeaders), /synthetic-consumption/);
|
|
assert.equal(content.debugContentView.getRequestCategory(entry), "consumption");
|
|
});
|
|
}
|
|
|
|
test("rate card fails locally for missing identifiers/credentials, HTTP, network, JSON and collection errors without retries", async () => {
|
|
for (const failure of ["id", "token", "401", "403", "network", "json", "shape", "item"]) {
|
|
let calls = 0;
|
|
const app = loadBackground({ fetch: async () => {
|
|
calls += 1;
|
|
if (["401", "403"].includes(failure)) return response({}, Number(failure));
|
|
if (failure === "network") throw new Error("Synthetic network failure");
|
|
if (failure === "json") return new Response("<html>fallback</html>");
|
|
return response(failure === "shape" ? {} : [null]);
|
|
} });
|
|
const result = await app.dispatch({ type, planNumber: failure === "id" ? "" : "A",
|
|
authorization: { value: failure === "token" ? "" : "synthetic" } });
|
|
assert.equal(result.ok, false, failure);
|
|
assert.ok(result.error, failure);
|
|
assert.equal(calls, ["id", "token"].includes(failure) ? 0 : 1, failure);
|
|
if (calls) assert.equal(result.debugRequests[0].status, "failed", failure);
|
|
}
|
|
});
|
|
|
|
test("all eleven columns are searchable, percentages and precise prices are formatted, and sorting is typed", () => {
|
|
const model = loadApplication("content").ratecardModel;
|
|
assert.equal(model.columns.length, 11);
|
|
for (const column of model.columns) {
|
|
assert.equal(model.selectItems([item], item[column.key]).length, 1, column.key);
|
|
assert.equal(model.selectItems([item], model.formatValue(item, column)).length, 1, column.key);
|
|
}
|
|
const format = (key, value) => model.formatValue({ [key]: value }, model.columns.find(column => column.key === key));
|
|
assert.equal(format("netUnitPriceUsd", "0.9589"), "0.9589");
|
|
assert.equal(format("listPriceUsd", 0), "0.0000");
|
|
assert.equal(format("rateCardDiscountPerc", 12.5), "12.5%");
|
|
assert.equal(format("usageMax", 0), "0");
|
|
assert.equal(format("subLnEndDt", null), "-");
|
|
assert.equal(model.selectItems([{ ...item, productName: "Synthet\u00edc" }], "synthetic").length, 1);
|
|
assert.equal(model.selectItems([{ ...item, hiddenMetadata: "secretmatch" }], "secretmatch").length, 0);
|
|
const rows = [
|
|
{ ...item, netUnitPriceUsd: "10", subLnStartSt: "01-Jan-2027" },
|
|
{ ...item, netUnitPriceUsd: "2", subLnStartSt: "31-Dec-2026" },
|
|
{ ...item, netUnitPriceUsd: null, subLnStartSt: null }
|
|
];
|
|
assert.deepEqual(plain(model.selectItems(rows, "", "netUnitPriceUsd").map(row => row.netUnitPriceUsd)), ["2", "10", null]);
|
|
assert.deepEqual(plain(model.selectItems(rows, "", "netUnitPriceUsd", "desc").map(row => row.netUnitPriceUsd)), ["10", "2", null]);
|
|
assert.deepEqual(plain(model.selectItems(rows, "", "subLnStartSt").map(row => row.subLnStartSt)), ["31-Dec-2026", "01-Jan-2027", null]);
|
|
assert.deepEqual(rows.map(row => row.netUnitPriceUsd), ["10", "2", null], "sorting does not mutate responses");
|
|
});
|
|
|
|
function setup() {
|
|
const document = { activeElement: null, documentElement: {} };
|
|
const walk = node => [node, ...node.children.flatMap(walk)];
|
|
class Element {
|
|
constructor(tag) {
|
|
this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.style = {}; this.dataset = {};
|
|
this.text = ""; this.value = ""; this.inert = false;
|
|
this.classList = { add: name => { this.className = `${this.className || ""} ${name}`; } };
|
|
}
|
|
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
|
|
set textContent(value) { this.text = String(value); this.children.forEach(node => { node.parent = null; }); this.children = []; }
|
|
get textContent() { return this.text; }
|
|
setAttribute(key, value) { this.attributes[key] = value; }
|
|
getAttribute(key) { return this.attributes[key]; }
|
|
addEventListener(name, callback) { this.listeners[name] = callback; }
|
|
focus(options) { document.activeElement = this; this.focusOptions = options; }
|
|
remove() { if (this.parent) this.parent.children = this.parent.children.filter(node => node !== this); this.parent = null; }
|
|
get isConnected() { return this === root || !!this.parent?.isConnected; }
|
|
querySelectorAll(selector) {
|
|
return walk(this).slice(1).filter(node => selector.split(",").some(part => {
|
|
const value = part.trim();
|
|
if (value.startsWith(".")) return (node.className || "").split(" ").includes(value.slice(1));
|
|
const attr = value.match(/^\[([^=]+)='([^']+)'\]$/);
|
|
return attr ? node.attributes[attr[1]] === attr[2] : node.tag === value;
|
|
}));
|
|
}
|
|
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
|
}
|
|
const root = new Element("main");
|
|
document.createElement = tag => new Element(tag);
|
|
document.createElementNS = (_, tag) => new Element(tag);
|
|
document.createTextNode = text => { const node = new Element("#text"); node.textContent = text; return node; };
|
|
document.getElementById = id => walk(root).find(node => node.id === id) || null;
|
|
const timers = new Map();
|
|
const frames = new Map();
|
|
let id = 0;
|
|
const app = loadApplication("content", { document, window: { addEventListener() {} },
|
|
requestAnimationFrame: fn => { frames.set(++id, fn); return id; }, cancelAnimationFrame: key => frames.delete(key),
|
|
setTimeout: fn => { timers.set(++id, fn); return id; }, clearTimeout: key => timers.delete(key) });
|
|
root.id = app.contracts.MODAL_ID;
|
|
app.consumptionPlanDetailController.loadPlanDetailForPanel = () => {};
|
|
app.consumptionTrendController.loadConsumptionTrendForPanel = () => {};
|
|
const plans = [{ contractId: "CONTRACT-A", planNumber: "PLAN-A", subPlanNum: "SUB-A", bookingCustomer: "Booking A", soldToPartyName: "Sold A", hdrStatusCode: "ACTIVE", startDate: "23-Aug-2024", endDate: "22-Aug-2028" },
|
|
{ contractId: "CONTRACT-B", planNumber: "PLAN-B", subPlanNum: "SUB-B", bookingCustomer: "Booking B", soldToPartyName: "Sold B", hdrStatusCode: "EXPIRED", startDate: "01-Jan-2020", endDate: "01-Jan-2024" }];
|
|
const opportunity = { OptyNumber: "SYNTH", CustomerAccount: { PartyUniqueName: "Synthetic Customer" } };
|
|
app.consumptionPlansView.openAccountPlansModal(opportunity, [], "loading");
|
|
app.consumptionPlansView.openAccountPlansModal(opportunity, plans);
|
|
timers.clear();
|
|
frames.clear();
|
|
const logs = [], requests = [];
|
|
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(entries);
|
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ accountPlans: { value: "synthetic-plan" } });
|
|
app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => requests.push({ message, resolve }));
|
|
const find = suffix => root.querySelector(`.opportunities-extension-ratecard-${suffix}`);
|
|
const buttons = root.querySelectorAll(".opportunities-extension-ratecard-button");
|
|
const nextFrame = () => { const callbacks = [...frames.values()]; frames.clear(); callbacks.forEach(fn => fn()); };
|
|
return { app, root, document, walk, timers, frames, nextFrame, plans, requests, logs, buttons, find };
|
|
}
|
|
const tick = () => new Promise(resolve => setImmediate(resolve));
|
|
|
|
test("Rate Card restores the last column and direction on reopening", async () => {
|
|
const { app, buttons, find, requests } = setup();
|
|
const opening = buttons[0].listeners.click(); await tick();
|
|
requests[0].resolve({ ok: true, items: [item], debugRequests: [] }); await opening;
|
|
find("table").querySelectorAll("th")[9].children[0].listeners.click();
|
|
find("table").querySelectorAll("th")[9].children[0].listeners.click();
|
|
app.ratecardView.closeRatecardDrawer({ immediate: true });
|
|
const reopened = buttons[0].listeners.click(); await tick();
|
|
if (requests[1]) requests[1].resolve({ ok: true, items: [item], debugRequests: [] });
|
|
await reopened;
|
|
assert.equal(find("table").querySelectorAll("th")[9].attributes["aria-sort"], "descending");
|
|
});
|
|
|
|
test("Rate card sits beside Contract ID, opens a skeleton, searches and sorts without HTML injection", async () => {
|
|
const { app, root, buttons, find, document, requests, logs } = setup();
|
|
assert.equal(buttons.length, 2);
|
|
assert.equal(buttons[0].textContent, "Rate Card");
|
|
assert.equal(buttons[0].parent.parent.className, "opportunities-extension-contract-row");
|
|
assert.equal(buttons[0].parent.children[0].textContent, "CONTRACT-A");
|
|
const opening = buttons[0].listeners.click();
|
|
assert.equal(find("drawer").attributes["aria-busy"], "true");
|
|
assert.equal(root.querySelectorAll(".opportunities-extension-ratecard-skeleton").length, 77);
|
|
assert.equal(document.activeElement, find("search"));
|
|
await tick();
|
|
assert.deepEqual(plain(requests[0].message), { type, planNumber: "SUB-A", authorization: { value: "synthetic-plan" } });
|
|
const parentHeader = root.querySelector(".opportunities-extension-account-plans-header");
|
|
assert.equal(find("header").querySelector("h2").textContent, "Rate Card");
|
|
assert.equal(parentHeader.querySelector("h2").textContent, "Cloud Consumption");
|
|
assert.equal(find("header").querySelector("p").textContent, parentHeader.querySelector("p").textContent);
|
|
assert.equal(find("header").querySelector("p").textContent, "Synthetic Customer");
|
|
assert.notEqual(find("header").querySelector("h2").id, parentHeader.querySelector("h2").id);
|
|
assert.ok(find("header").className.includes("opportunities-extension-account-plans-header"));
|
|
const details = find("plan-details");
|
|
assert.deepEqual(details.querySelectorAll("dt").map(node => node.textContent), ["Booking Customer", "Sold To Party Name", "Contract ID", "Header Status", "Start Date", "End Date"]);
|
|
assert.deepEqual(details.querySelectorAll("dd").map(node => node.textContent), ["Booking A", "Sold A", "CONTRACT-A", "ACTIVE", "23-Aug-2024", "22-Aug-2028"]);
|
|
assert.equal(details.querySelectorAll("button").length, 0, "no recursive Rate Card action inside its own drawer");
|
|
const unsafe = "<img src=x onerror=bad()>";
|
|
requests[0].resolve({ ok: true, items: [{ ...item, netUnitPriceUsd: "10", productName: unsafe }, item], debugRequests: [{ name: "requestRatecardByPlan" }] });
|
|
await opening;
|
|
assert.equal(find("drawer").attributes["aria-busy"], "false");
|
|
assert.equal(find("count").textContent, "2 of 2 items");
|
|
assert.equal(root.querySelectorAll("img").length, 0);
|
|
const table = find("table");
|
|
assert.equal(table.querySelector("tbody").children[0].children[2].textContent, unsafe);
|
|
assert.equal(logs[0][0].name, "requestRatecardByPlan");
|
|
const priceSort = table.querySelectorAll("th")[9];
|
|
priceSort.children[0].listeners.click();
|
|
assert.equal(priceSort.attributes["aria-sort"], "ascending");
|
|
assert.equal(table.querySelector("tbody").children[0].children[9].textContent, "0.9589");
|
|
priceSort.children[0].listeners.click();
|
|
assert.equal(priceSort.attributes["aria-sort"], "descending");
|
|
assert.equal(table.querySelector("tbody").children[0].children[9].textContent, "10.0000");
|
|
find("search").value = "12.5%";
|
|
find("search").listeners.input();
|
|
assert.equal(find("count").textContent, "2 of 2 items");
|
|
find("search").value = "no match";
|
|
find("search").listeners.input();
|
|
assert.equal(find("count").textContent, "0 of 2 items");
|
|
assert.equal(find("notice").children[0].textContent, "No matching rate card items.");
|
|
find("header").querySelector("button").listeners.click();
|
|
assert.equal(root.querySelector(".opportunities-extension-account-plans-modal").hidden, undefined, "drawer close keeps Cloud Consumption open");
|
|
app.ratecardView.closeRatecardDrawer({ immediate: true });
|
|
});
|
|
|
|
test("quick clicks share a request, tab changes discard stale results, closing restores focus and keyboard stays inside", async () => {
|
|
const { app, root, buttons, requests, find, document, timers } = setup();
|
|
const first = buttons[0].listeners.click();
|
|
const repeat = buttons[0].listeners.click();
|
|
await tick();
|
|
assert.equal(requests.length, 1);
|
|
root.querySelectorAll("[role='tab']")[1].listeners.click();
|
|
assert.equal(find("overlay"), null);
|
|
const second = buttons[1].listeners.click();
|
|
await tick();
|
|
assert.equal(requests[1].message.planNumber, "SUB-B");
|
|
assert.deepEqual(find("plan-details").querySelectorAll("dd").map(node => node.textContent), ["Booking B", "Sold B", "CONTRACT-B", "EXPIRED", "01-Jan-2020", "01-Jan-2024"]);
|
|
requests[1].resolve({ ok: true, items: [item] });
|
|
await second;
|
|
requests[0].resolve({ ok: true, items: [{ ...item, productName: "WRONG PLAN" }] });
|
|
await Promise.all([first, repeat]);
|
|
assert.equal(find("table").querySelector("tbody").children[0].children[2].textContent, item.productName);
|
|
const background = root.querySelector(".opportunities-extension-account-plans-dialog");
|
|
assert.equal(background.inert, true);
|
|
const overlay = find("overlay");
|
|
const close = find("header").querySelector("button");
|
|
close.focus();
|
|
let prevented = 0, stopped = 0;
|
|
overlay.listeners.keydown({ key: "Tab", shiftKey: true, preventDefault() { prevented++; } });
|
|
assert.equal(document.activeElement, find("table").querySelectorAll("button")[10]);
|
|
overlay.listeners.keydown({ key: "Tab", preventDefault() { prevented++; } });
|
|
assert.equal(document.activeElement, close);
|
|
overlay.listeners.keydown({ key: "Escape", preventDefault() { prevented++; }, stopPropagation() { stopped++; } });
|
|
assert.equal(prevented, 3);
|
|
assert.equal(stopped, 1);
|
|
assert.equal(overlay.dataset.closing, "true");
|
|
assert.equal(document.activeElement, buttons[1]);
|
|
assert.equal(background.inert, false);
|
|
assert.equal(timers.size, 1);
|
|
const reopening = buttons[1].listeners.click();
|
|
assert.equal(timers.size, 0);
|
|
assert.equal(overlay.isConnected, false);
|
|
await tick();
|
|
app.consumptionPlansView.closeAccountPlansModal();
|
|
assert.equal(find("overlay"), null);
|
|
requests[2].resolve({ ok: true, items: [item] });
|
|
await reopening;
|
|
assert.equal(find("overlay"), null, "late responses never reopen a closed drawer");
|
|
});
|
|
|
|
test("missing subPlanNum does not use another identifier; errors are local, empty results and retry on reopening work", async () => {
|
|
const { app, plans, buttons, requests, find } = setup();
|
|
delete plans[0].subPlanNum;
|
|
await buttons[0].listeners.click();
|
|
assert.match(find("notice").children[0].textContent, /Plan number is unavailable/);
|
|
assert.equal(requests.length, 0);
|
|
const failed = buttons[1].listeners.click();
|
|
await tick();
|
|
requests[0].resolve({ ok: false, error: "Synthetic 401", debugRequests: [] });
|
|
await failed;
|
|
assert.equal(find("notice").children[0].attributes.role, "alert");
|
|
assert.equal(find("notice").children[0].textContent, "Synthetic 401");
|
|
const retry = buttons[1].listeners.click();
|
|
await tick();
|
|
requests[1].resolve({ ok: true, items: [] });
|
|
await retry;
|
|
assert.equal(find("notice").children[0].textContent, "No rate card items found for this plan.");
|
|
find("overlay").listeners.click({ target: find("overlay") });
|
|
app.ratecardView.closeRatecardDrawer({ immediate: true });
|
|
assert.equal(find("overlay"), null);
|
|
});
|
|
|
|
test("opening a plan without planNumber sends its subPlanNum as the payload planNumber", async () => {
|
|
const { plans, buttons, requests } = setup();
|
|
delete plans[0].planNumber;
|
|
const opening = buttons[0].listeners.click();
|
|
await tick();
|
|
assert.equal(requests.length, 1);
|
|
assert.equal(requests[0].message.planNumber, "SUB-A");
|
|
requests[0].resolve({ ok: true, items: [] });
|
|
await opening;
|
|
});
|
|
|
|
test("drawer starts offscreen, opens after a painted frame and never scrolls when focused", () => {
|
|
const { app, buttons, plans, find, frames, nextFrame } = setup();
|
|
app.ratecardView.openRatecardDrawer(plans[0], buttons[0]);
|
|
const overlay = find("overlay");
|
|
assert.equal(overlay.dataset.open, "false");
|
|
assert.equal(find("search").focusOptions.preventScroll, true);
|
|
nextFrame();
|
|
assert.equal(overlay.dataset.open, "false", "offscreen position gets one full paint");
|
|
nextFrame();
|
|
assert.equal(overlay.dataset.open, "true");
|
|
assert.equal(frames.size, 0);
|
|
app.ratecardView.closeRatecardDrawer({ immediate: true });
|
|
assert.equal(buttons[0].focusOptions.preventScroll, true);
|
|
for (const painted of [false, true]) {
|
|
app.ratecardView.openRatecardDrawer(plans[0], buttons[0]);
|
|
const cancelled = find("overlay");
|
|
if (painted) nextFrame();
|
|
app.ratecardView.closeRatecardDrawer({ immediate: true });
|
|
assert.equal(frames.size, 0);
|
|
nextFrame();
|
|
assert.equal(cancelled.dataset.open, "false", "closing cancels pending entry frames");
|
|
assert.equal(cancelled.isConnected, false);
|
|
}
|
|
});
|
|
|
|
test("drawer CSS supports both themes, reduced motion, bounded scrolling and separate header icon space", () => {
|
|
const css = fs.readFileSync(path.join(__dirname, "../src/features/ratecard/styles.css"), "utf8");
|
|
assert.match(css, /data-theme="dark"/);
|
|
const buttonStyle = css.match(/\.opportunities-extension-ratecard-button \{([^}]+)\}/)[1];
|
|
assert.match(buttonStyle, /display: inline-flex/);
|
|
assert.match(buttonStyle, /align-items: center/);
|
|
assert.match(buttonStyle, /height: 28px/);
|
|
assert.match(buttonStyle, /line-height: 20px/);
|
|
assert.match(css, /\.opportunities-extension-contract-ratecard > span \{\s*line-height: 20px/);
|
|
assert.match(css, /prefers-reduced-motion/);
|
|
assert.match(css, /translateX\(100%\)/);
|
|
assert.match(css, /transition: transform 320ms/);
|
|
assert.match(css, /\[data-open="true"\] \.opportunities-extension-ratecard-drawer \{ transform: translateX\(0\); \}/);
|
|
assert.match(css, /overflow: clip/, "the overlay cannot be scrolled by focus during entry");
|
|
assert.doesNotMatch(css, /opportunities-ratecard-(?:fade|slide|backdrop)-in/);
|
|
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
|
|
assert.match(css, /min-width: 1100px/);
|
|
assert.match(css, /grid-template-rows: auto auto auto minmax\(0, 1fr\)/);
|
|
assert.doesNotMatch(css, /\.opportunities-extension-account-plans-dialog\s*\{/);
|
|
});
|