Implemented the Plan Detail view to display consumption overages or forfeitures
This commit is contained in:
264
tests/consumptionPlanDetail.test.cjs
Normal file
264
tests/consumptionPlanDetail.test.cjs
Normal file
@@ -0,0 +1,264 @@
|
||||
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 detail = {
|
||||
projectedStatus: "Forfeit", pricingModel: "Funded Allocation", lineStatus: "ACTIVE",
|
||||
startDate: "17-Aug-2023", endDate: "16-Aug-2027", committedCredits: 7488782,
|
||||
consumed: 4106739, consumptionL30Days: 152200, monthsRemaining: null,
|
||||
projectedConsumption: null, projectedDelta: null, currencyCode: "BRL", orderId: "SYNTH-ORDER",
|
||||
orderTrackerURL: "https://accountdashboard.oracle.com/app/udb/?appId=12#/ordertracker/orderNumber/SYNTH-ORDER",
|
||||
clines: [{ projectedStatus: "Overage", pricingModel: "Funded Allocation", monthsRemaining: 0,
|
||||
committedCredits: 100, consumed: 110, projectedConsumption: 120, projectedDelta: -20,
|
||||
orderId: "SYNTH-CHILD", orderTrackerURL: "https://accountdashboard.oracle.com/app/udb/#/ordertracker/orderNumber/SYNTH-CHILD" }]
|
||||
};
|
||||
const type = "opportunitiesExtension.requestConsumptionPlanDetail";
|
||||
for (const family of ["chrome", "firefox"]) {
|
||||
test(`${family}: plan details use OCICONS value in G2m-Authorization, encoded subPlanNum, and 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: "unused-label", value: "synthetic-plan" } }
|
||||
]);
|
||||
return response([detail]);
|
||||
} });
|
||||
const auth = await app.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
|
||||
const result = await app.dispatch({ type, subPlanNum: "plan/test", authorization: auth.authorizations.accountPlans });
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.items, [detail]);
|
||||
assert.equal(result.subPlanNum, "plan/test");
|
||||
assert.equal(calls[1].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-consumption/service/v1/plan/plandetail/plan%2Ftest?active=true");
|
||||
assert.equal(calls[1].options.method, "GET");
|
||||
assert.equal(calls[1].options.credentials, "include");
|
||||
assert.equal(calls[1].options.cache, "no-store");
|
||||
assert.equal(calls[1].options.body, undefined);
|
||||
assert.deepEqual(plain(calls[1].options.headers), { Accept: "application/json", "G2m-Authorization": "synthetic-plan" });
|
||||
assert.equal(result.debugRequests[0].name, "requestConsumptionPlanDetail");
|
||||
assert.equal(result.debugRequests[0].httpStatus, 200);
|
||||
const content = loadApplication("content");
|
||||
assert.doesNotMatch(content.debugContentLog.formatHeaderBlock(result.debugRequests[0].requestHeaders), /synthetic-plan/);
|
||||
assert.equal(content.debugContentView.getRequestCategory(result.debugRequests[0]), "consumption");
|
||||
});
|
||||
}
|
||||
test("missing token/subPlanNum, HTTP, network, invalid JSON and malformed nested lines do not silently succeed", async () => {
|
||||
for (const kind of ["token", "id", "http", "network", "json", "array", "clines"]) {
|
||||
let calls = 0;
|
||||
const app = loadBackground({ fetch: async () => {
|
||||
calls += 1;
|
||||
if (kind === "http") return response({}, 403);
|
||||
if (kind === "network") throw new Error("Synthetic network failure");
|
||||
if (kind === "json") return new Response("<html>fallback</html>");
|
||||
return response(kind === "array" ? {} : [{ clines: {} }]);
|
||||
} });
|
||||
const result = await app.dispatch({ type, subPlanNum: kind === "id" ? "" : "test",
|
||||
authorization: { value: kind === "token" ? "" : "synthetic-plan" } });
|
||||
assert.equal(result.ok, false, kind);
|
||||
assert.ok(result.error, kind);
|
||||
assert.equal(calls, ["token", "id"].includes(kind) ? 0 : 1);
|
||||
if (calls) assert.equal(result.debugRequests[0].status, "failed");
|
||||
}
|
||||
});
|
||||
|
||||
function createDom() {
|
||||
const document = { activeElement: null, listeners: new Map(), documentElement: {} };
|
||||
const window = { innerWidth: 420, innerHeight: 640, listeners: new Map() };
|
||||
for (const target of [document, window]) {
|
||||
target.addEventListener = (name, fn) => { if (!target.listeners.has(name)) target.listeners.set(name, new Set()); target.listeners.get(name).add(fn); };
|
||||
target.removeEventListener = (name, fn) => target.listeners.get(name)?.delete(fn);
|
||||
}
|
||||
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.hidden = false; this.text = "";
|
||||
this.classList = { add: name => { this.className = `${this.className || ""} ${name}`.trim(); } };
|
||||
}
|
||||
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, fn) { this.listeners[name] = fn; }
|
||||
removeEventListener(name) { delete this.listeners[name]; }
|
||||
focus() { document.activeElement = this; }
|
||||
contains(node) { return !!node && walk(this).includes(node); }
|
||||
remove() { if (this.parent) this.parent.children = this.parent.children.filter(child => child !== 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; }
|
||||
getBoundingClientRect() { return this.tag === "button" ? { left: 350, top: 530, bottom: 550, height: 20 } : { height: 500 }; }
|
||||
}
|
||||
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();
|
||||
let timerId = 0;
|
||||
return { root, document, window, walk, timers,
|
||||
setTimeout: fn => { timers.set(++timerId, fn); return timerId; }, clearTimeout: id => timers.delete(id) };
|
||||
}
|
||||
function setup() {
|
||||
const dom = createDom();
|
||||
const app = loadApplication("content", { ...dom, URL, Intl, requestAnimationFrame: () => {} });
|
||||
dom.root.id = app.contracts.MODAL_ID;
|
||||
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||
return { ...dom, app };
|
||||
}
|
||||
|
||||
test("nested tooltip preserves values/null/zero and restricts order links to the expected HTTPS origin", () => {
|
||||
const { app, root, walk } = setup();
|
||||
app.consumptionPlanDetailView.renderPlanDetailBadges(root, [detail, { projectedStatus: "Other", pricingModel: "<img onerror=bad>", orderId: "bad", orderTrackerURL: "javascript:bad()" }]);
|
||||
assert.equal(root.children.length, 2);
|
||||
assert.equal(root.children[0].children[0].dataset.status, "forfeit");
|
||||
const tooltip = root.children[0].children[1];
|
||||
assert.equal(tooltip.hidden, true);
|
||||
const nesting = tooltip.querySelector(".opportunities-extension-projection-children");
|
||||
assert.ok(nesting);
|
||||
assert.equal(nesting.querySelector(".opportunities-extension-projection-badge").dataset.status, "overage");
|
||||
const terms = tooltip.querySelectorAll("dt");
|
||||
assert.equal(terms.length, 22);
|
||||
assert.ok(terms.some(term => term.textContent === "Order number"));
|
||||
const values = tooltip.querySelectorAll("dd").map(value => value.textContent);
|
||||
assert.ok(values.includes("7,488,782.00"));
|
||||
assert.ok(values.includes("-20.00"));
|
||||
assert.ok(values.includes("0"));
|
||||
assert.ok(values.includes("-"));
|
||||
assert.ok(values.includes("17-Aug-2023"));
|
||||
assert.equal(root.querySelectorAll("a").length, 2);
|
||||
assert.ok(root.querySelectorAll("a").every(link => link.target === "_blank" && link.rel === "noopener noreferrer"));
|
||||
assert.ok(walk(root).some(node => node.textContent === "<img onerror=bad>"));
|
||||
for (const orderTrackerURL of ["https://accountdashboard.oracle.com.evil.example/x", "https://user@accountdashboard.oracle.com/x", "http://accountdashboard.oracle.com/x"]) {
|
||||
assert.equal(app.consumptionPlanDetailView.createLineDetails({ orderId: "test", orderTrackerURL }).querySelectorAll("a").length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("hover, click, keyboard, outside dismissal and cleanup keep the popover within the viewport", () => {
|
||||
const { app, root, document, window, timers } = setup();
|
||||
app.consumptionPlanDetailView.renderPlanDetailBadges(root, [detail]);
|
||||
const wrapper = root.children[0];
|
||||
const [button, tooltip] = wrapper.children;
|
||||
wrapper.listeners.pointerenter();
|
||||
assert.equal(tooltip.hidden, false);
|
||||
assert.equal(button.attributes["aria-expanded"], "true");
|
||||
assert.ok(parseFloat(tooltip.style.left) + parseFloat(tooltip.style.width) <= window.innerWidth);
|
||||
assert.ok(parseFloat(tooltip.style.top) >= 12);
|
||||
wrapper.listeners.pointerleave();
|
||||
assert.equal(timers.size, 1);
|
||||
wrapper.listeners.pointerenter();
|
||||
assert.equal(timers.size, 0);
|
||||
button.listeners.click();
|
||||
wrapper.listeners.pointerleave();
|
||||
assert.equal(timers.size, 0, "click pins the tooltip so the order links remain usable");
|
||||
let stopped = false;
|
||||
wrapper.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() { stopped = true; } });
|
||||
assert.equal(stopped, true);
|
||||
assert.equal(tooltip.hidden, true);
|
||||
assert.equal(document.activeElement, button);
|
||||
assert.equal(document.listeners.get("pointerdown").size, 0);
|
||||
assert.equal(window.listeners.get("resize").size, 0);
|
||||
wrapper.listeners.focusin();
|
||||
for (const listener of document.listeners.get("pointerdown")) listener({ target: root });
|
||||
assert.equal(tooltip.hidden, true);
|
||||
});
|
||||
|
||||
test("controller shares in-flight requests, preserves per-panel identity and ignores removed panels", async () => {
|
||||
const { app, root, document } = setup();
|
||||
const calls = [];
|
||||
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ accountPlans: { value: "synthetic-plan" } });
|
||||
app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => calls.push({ message, resolve }));
|
||||
const panel = document.createElement("section");
|
||||
panel.append(app.consumptionPlanDetailController.createPlanDetailSection({ subPlanNum: "A" }));
|
||||
root.append(panel);
|
||||
const first = app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
|
||||
const repeat = app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
|
||||
assert.equal(first, repeat);
|
||||
assert.equal(panel.children[0].attributes["aria-busy"], "true");
|
||||
const other = document.createElement("section");
|
||||
other.append(app.consumptionPlanDetailController.createPlanDetailSection({ subPlanNum: "B" }));
|
||||
root.append(other);
|
||||
const second = app.consumptionPlanDetailController.loadPlanDetailForPanel(other, { subPlanNum: "B" });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(plain(calls[0].message), { type, subPlanNum: "A", authorization: { value: "synthetic-plan" } });
|
||||
calls[1].resolve({ ok: true, items: [{ projectedStatus: "Overage" }] });
|
||||
await second;
|
||||
panel.remove();
|
||||
calls[0].resolve({ ok: true, items: [detail] });
|
||||
await first;
|
||||
assert.equal(other.querySelector(".opportunities-extension-projection-badge").textContent, "Overage");
|
||||
assert.equal(panel.querySelector(".opportunities-extension-projection-badge"), null);
|
||||
await app.consumptionPlanDetailController.loadPlanDetailForPanel(other, { subPlanNum: "B" });
|
||||
assert.equal(calls.length, 2, "ready details remain in the current modal only");
|
||||
});
|
||||
|
||||
test("Cloud Consumption loads details for the first tab and each newly selected plan, without delaying trends", () => {
|
||||
const { app, root, document } = setup();
|
||||
const calls = [];
|
||||
const trends = [];
|
||||
app.consumptionPlanDetailController.loadPlanDetailForPanel = (_, plan) => calls.push(plan.subPlanNum);
|
||||
app.consumptionTrendController.loadConsumptionTrendForPanel = (_, plan) => trends.push(plan.subPlanNum);
|
||||
app.consumptionPlansView.openAccountPlansModal({ OptyNumber: "SYNTH" }, [], "loading");
|
||||
app.consumptionPlansView.openAccountPlansModal({ OptyNumber: "SYNTH" }, [{ subPlanNum: "A" }, { subPlanNum: "B" }], "ready");
|
||||
assert.deepEqual(calls, ["A"]);
|
||||
assert.deepEqual(trends, ["A"]);
|
||||
const badges = root.querySelector(".opportunities-extension-plan-detail-badges");
|
||||
const trendHeader = root.querySelector(".opportunities-extension-consumption-trend-header");
|
||||
assert.equal(trendHeader.children[0], badges, "projection badges are left of the period controls");
|
||||
assert.equal(trendHeader.children[1].className, "opportunities-extension-consumption-periods");
|
||||
assert.deepEqual(trendHeader.children[1].children.map(button => button.textContent), ["Quarterly", "Monthly", "Weekly", "Daily"]);
|
||||
assert.equal(root.querySelector(".opportunities-extension-account-plan-details").querySelector(".opportunities-extension-plan-detail-badges"), null);
|
||||
app.consumptionPlanDetailView.renderPlanDetailBadges(badges, [detail]);
|
||||
const wrapper = badges.children[0];
|
||||
wrapper.listeners.pointerenter();
|
||||
root.querySelectorAll("[role='tab']")[1].listeners.click();
|
||||
assert.equal(wrapper.children[1].hidden, true, "switching tabs closes the previous tooltip");
|
||||
assert.deepEqual(calls, ["A", "B"]);
|
||||
assert.deepEqual(trends, ["A", "B"]);
|
||||
assert.equal(root.querySelectorAll(".opportunities-extension-plan-detail-badges").length, 2);
|
||||
app.consumptionPlansView.closeAccountPlansModal();
|
||||
assert.equal(document.getElementById(app.contracts.ACCOUNT_PLANS_MODAL_ID).hidden, true);
|
||||
assert.equal(document.listeners.get("pointerdown").size, 0);
|
||||
});
|
||||
|
||||
test("a detail failure stays local and the next activation can retry and display the empty state", async () => {
|
||||
const { app, root, document } = setup();
|
||||
const panel = document.createElement("section");
|
||||
const section = app.consumptionPlanDetailController.createPlanDetailSection({ subPlanNum: "A" });
|
||||
panel.append(section);
|
||||
root.append(panel);
|
||||
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ accountPlans: { value: "synthetic-plan" } });
|
||||
let count = 0;
|
||||
app.platformContentMessages.sendRuntimeMessage = async () => ++count === 1
|
||||
? { ok: false, error: "Synthetic detail failure" } : { ok: true, items: [] };
|
||||
await app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
|
||||
assert.equal(section.attributes["aria-busy"], "false");
|
||||
assert.equal(section.children[0].attributes.role, "alert");
|
||||
assert.equal(section.children[0].textContent, "Synthetic detail failure");
|
||||
await app.consumptionPlanDetailController.loadPlanDetailForPanel(panel, { subPlanNum: "A" });
|
||||
assert.equal(count, 2);
|
||||
assert.equal(section.children[0].textContent, "No projection data");
|
||||
});
|
||||
|
||||
test("plan projection CSS is scoped, has red/blue states in both themes and bounds only the tooltip scrolling", () => {
|
||||
const css = fs.readFileSync(path.join(__dirname, "../src/features/consumption/plan-detail.css"), "utf8");
|
||||
assert.match(css, /data-status="forfeit"/);
|
||||
assert.match(css, /data-status="overage"/);
|
||||
assert.match(css, /data-theme="dark"/);
|
||||
assert.match(css, /position: fixed/);
|
||||
const headerRule = css.match(/\.opportunities-extension-consumption-trend-header \{([^}]+)\}/)[1];
|
||||
assert.match(headerRule, /justify-content: space-between/, "badges align with the chart's left edge while period controls stay right");
|
||||
assert.match(css, /prefers-reduced-motion/);
|
||||
assert.doesNotMatch(css, /\.opportunities-extension-account-plans-dialog\s*\{/);
|
||||
});
|
||||
@@ -28,6 +28,7 @@ test("request categories cover every current family and unknown requests stay vi
|
||||
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
|
||||
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
|
||||
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
|
||||
requestConsumptionPlanDetail: "consumption",
|
||||
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "consumption",
|
||||
requestConsumptionServiceWeekly: "consumption", requestConsumptionServiceDaily: "consumption", futureRequest: "other"
|
||||
})) assert.equal(h.app.debugContentView.getRequestCategory({ name }), category, name);
|
||||
@@ -94,7 +95,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", "src/features/service-requests/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", "src/features/consumption/plan-detail.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"));
|
||||
|
||||
@@ -158,6 +158,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
||||
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", "src/features/service-requests/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", "src/features/consumption/plan-detail.css"].map(read).join("\n");
|
||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user