Files
opportunities-extension/tests/aiSummary.test.cjs

226 lines
11 KiB
JavaScript

const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
class Element {
constructor(tag) {
this.tag = tag;
this.children = [];
this.dataset = {};
this.attributes = {};
this.listeners = {};
this.classes = new Set();
this.classList = { toggle: (name, on) => on ? this.classes.add(name) : this.classes.delete(name) };
}
append(...children) { this.children.push(...children); }
setAttribute(name, value) { this.attributes[name] = value; }
addEventListener(name, callback) { this.listeners[name] = callback; }
replaceWith(element) { this.replacement = element; }
click() { this.listeners.click({ preventDefault() {}, stopPropagation() {} }); }
}
function setup(mockSalesToken = true) {
const calls = [];
let now = Date.now();
class TestDate extends Date {
constructor(...args) { super(...(args.length ? args : [now])); }
static now() { return now; }
}
let visibleAlerts = [];
const source = fs.readFileSync(path.join(__dirname, "../src/content.js"), "utf8");
const marker = " if (!isAllowedPage()) {";
assert.ok(source.includes(marker));
const instrumented = source.replace(marker, `
accessToken = "test-bearer-secret";
getXsrfToken = async () => ({ token: "test-xsrf-cookie", cookieName: "XSRF-TOKEN-DYNAMIC", source: "test" });
refreshXsrfCookie = async () => ({ refreshed: false });
${mockSalesToken ? 'requestTokenRelaySalesCommon = async () => "test-sales-common-secret";' : ''}
renderDebugPanel = () => {};
renderOpportunitiesTable = () => {};
globalThis.helpers = {
requestGenerateAiSummary, createProductsCountButton, createProductsDetailRow,
createAiSummaryAlert, aiSummariesByOptyNumber, expandedProductOptyNumbers,
opptyProductsByNumber, requestLog, formatHeaderBlock, requestTokenRelay,
getAccessToken: () => accessToken, requestTokenRelaySalesCommon
};
return;
${marker}`);
const context = {
document: {
createElement: (tag) => new Element(tag),
querySelectorAll: () => visibleAlerts
},
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),
Intl, Date: TestDate, Map, Set
};
vm.runInNewContext(instrumented, context);
return { ...context.helpers, calls, advance: (ms) => { now += ms; }, show: (alerts) => { visibleAlerts = alerts; } };
}
function json(data, status = 200) {
return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } });
}
const flush = () => new Promise((resolve) => setImmediate(resolve));
async function run() {
const h = setup();
const products = { items: [{ ProdGroupName: "Cloud", RevnAmount: "10000", WinProb: 30 }] };
h.opptyProductsByNumber.set("A1", products);
h.createProductsCountButton("A1", products).click();
const first = h.aiSummariesByOptyNumber.get("A1").promise;
await flush();
assert.equal(h.calls.length, 1);
assert.equal(h.requestGenerateAiSummary("A1"), first);
assert.equal(h.calls.length, 1, "in-flight requests are deduplicated");
const { url, options } = h.calls[0];
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/api/sales-common/gen-ai/v1/summarizations/generate");
assert.equal(options.method, "POST");
assert.equal(options.headers.Authorization, "Bearer test-sales-common-secret");
assert.equal(options.headers["Content-Type"], "application/json");
assert.deepEqual(JSON.parse(options.body), {
path: "/api/sales-common/gen-ai/v1/summarizations/generate",
promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: "A1"
});
const loadingRow = h.createProductsDetailRow({ OptyNumber: "A1" });
const panel = loadingRow.children[0].children[0];
assert.equal(panel.children[1].attributes["aria-busy"], "true");
assert.equal(panel.children[2].tag, "table", "products remain below the summary");
assert.equal(panel.children[2].children[1].children.length, 1);
h.createProductsCountButton("A1", products).click();
assert.equal(h.calls.length, 1, "collapsing does not request another summary");
assert.equal(h.expandedProductOptyNumbers.has("A1"), false);
const second = h.requestGenerateAiSummary("A2");
await flush();
const visible = h.createAiSummaryAlert("A2");
h.show([visible]);
h.calls[1].resolve(json({ summaryText: "Second opportunity" }));
await second;
assert.equal(visible.replacement.children[1].textContent, "Second opportunity");
const unsafeText = "<img src=x onerror=alert(1)>\nLong summary";
h.calls[0].resolve(json({ summaryText: unsafeText, lastUpdateDate: "not displayed" }));
await first;
assert.equal(h.expandedProductOptyNumbers.has("A1"), false, "late responses do not reopen rows");
assert.equal(visible.replacement.children[1].textContent, "Second opportunity");
const ready = h.createAiSummaryAlert("A1");
assert.equal(ready.children[0].textContent, "Summary generated by AI");
assert.equal(ready.children[1].textContent, unsafeText);
assert.equal(ready.children[1].children.length, 0, "AI text is not parsed as HTML");
assert.equal(ready.children.length, 2);
assert.equal(h.requestLog[0].name, "requestGenerateAiSummary");
assert.equal(h.requestLog[0].httpStatus, 200);
assert.equal(h.requestLog[0].status, "success");
assert.ok(h.requestLog[0].responseBody.includes("summaryText"));
assert.ok(!h.formatHeaderBlock(h.requestLog[0].requestHeaders).includes("test-sales-common-secret"));
h.createProductsCountButton("A1", products).click();
await flush();
assert.equal(h.calls.length, 3, "expanding again generates a fresh summary");
h.createProductsCountButton("A1", products).click();
h.createProductsCountButton("A1", products).click();
assert.equal(h.calls.length, 3, "rapid collapse/expand reuses the pending request");
h.calls[2].resolve(json({ summaryText: "Fresh summary" }));
await h.aiSummariesByOptyNumber.get("A1").promise;
for (const response of [json({}, 500), new Response("<html>login</html>"), json({}), json({ summaryText: " " }), json({ summaryText: 123 })]) {
const failure = h.requestGenerateAiSummary("error");
await flush();
h.calls.at(-1).resolve(response);
await failure;
assert.equal(h.aiSummariesByOptyNumber.get("error").status, "error");
assert.equal(h.requestLog.at(-1).status, "failed");
assert.ok(h.requestLog.at(-1).httpStatus);
assert.ok(h.createAiSummaryAlert("error").classes.has("opportunities-extension-ai-summary-error"));
}
const networkFailure = h.requestGenerateAiSummary("error");
await flush();
h.calls.at(-1).reject(new Error("Network unavailable"));
await networkFailure;
assert.equal(h.requestLog.at(-1).error, "Network unavailable");
const retry = h.requestGenerateAiSummary("error");
await flush();
h.calls.at(-1).resolve(json({ summaryText: "Recovered" }));
await retry;
assert.equal(h.aiSummariesByOptyNumber.get("error").status, "ready");
const scoped = setup(false);
const generation = scoped.requestGenerateAiSummary("SC1");
await flush();
assert.equal(scoped.calls.length, 1, "summary waits for its scoped token");
assert.equal(scoped.calls[0].url, "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F");
assert.equal(scoped.calls[0].options.method, "GET");
assert.equal(scoped.calls[0].options.credentials, "include");
assert.equal(scoped.calls[0].options.headers["x-xsrf-token"], "test-xsrf-cookie");
scoped.calls[0].resolve(json({ access_token: "scoped-token", expires_in: 3600 }));
await flush();
assert.equal(scoped.calls[1].options.headers.Authorization, "Bearer scoped-token");
scoped.calls[1].resolve(json({ summaryText: "Scoped summary" }));
await generation;
assert.equal(scoped.requestLog[0].name, "requestTokenRelaySalesCommon");
assert.equal(scoped.requestLog[0].httpStatus, 200);
assert.equal(scoped.getAccessToken(), "test-bearer-secret", "scoped token does not replace the general token");
const normal = scoped.requestTokenRelay();
await flush();
assert.equal(scoped.calls[2].url, "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay");
assert.equal(scoped.calls[2].options.headers["x-xsrf-token"], scoped.calls[0].options.headers["x-xsrf-token"]);
scoped.calls[2].resolve(json({ access_token: "normal-token" }));
await normal;
assert.equal(scoped.requestLog[2].name, "requestTokenRelay");
assert.equal(await scoped.requestTokenRelaySalesCommon(), "scoped-token");
assert.equal(scoped.calls.length, 3, "valid token is reused");
scoped.advance(3600 * 1000);
const failedScope = scoped.requestGenerateAiSummary("SC2");
await flush();
scoped.calls[3].resolve(json({ error: "Unavailable" }, 500));
await failedScope;
assert.equal(scoped.calls.length, 4, "no summary POST after scoped authentication failure");
assert.equal(scoped.aiSummariesByOptyNumber.get("SC2").status, "error");
assert.equal(scoped.requestLog[3].status, "failed");
const pendingToken = scoped.requestTokenRelaySalesCommon();
assert.equal(scoped.requestTokenRelaySalesCommon(), pendingToken, "concurrent refresh is deduplicated");
await flush();
scoped.calls[4].resolve(json({ access_token: "renewed-token", expires_in: 3600 }));
await pendingToken;
assert.equal(await scoped.requestTokenRelaySalesCommon(), "renewed-token");
assert.equal(scoped.calls.length, 5, "failed refresh can be retried and then cached");
const rejected = scoped.requestGenerateAiSummary("REJECTED");
const concurrent = scoped.requestGenerateAiSummary("CONCURRENT");
await flush();
assert.equal(scoped.calls[5].options.headers.Authorization, "Bearer renewed-token");
scoped.calls[5].resolve(json({}, 401));
await flush();
assert.equal(scoped.calls[7].options.method, "GET");
scoped.calls[7].resolve(json({ access_token: "replacement-token", expires_in: 3600 }));
await flush();
assert.equal(scoped.calls[8].options.headers.Authorization, "Bearer replacement-token");
scoped.calls[8].resolve(json({ summaryText: "Recovered after refresh" }));
await rejected;
scoped.calls[6].resolve(json({}, 401));
await flush();
assert.equal(scoped.calls[9].options.method, "POST", "late rejection must reuse the already refreshed token");
assert.equal(scoped.calls[9].options.headers.Authorization, "Bearer replacement-token");
scoped.calls[9].resolve(json({ summaryText: "Concurrent recovery" }));
await concurrent;
assert.equal(scoped.calls.length, 10);
const forbidden = scoped.requestGenerateAiSummary("FORBIDDEN");
await flush();
scoped.calls[10].resolve(json({}, 403));
await flush();
scoped.calls[11].resolve(json({ access_token: "still-forbidden", expires_in: 3600 }));
await flush();
scoped.calls[12].resolve(json({}, 403));
await forbidden;
assert.equal(scoped.calls.length, 13, "only one renewal and one summary retry per generation");
assert.equal(scoped.aiSummariesByOptyNumber.get("FORBIDDEN").status, "error");
assert.equal(scoped.requestLog.at(-1).httpStatus, 403);
assert.equal(scoped.getAccessToken(), "test-bearer-secret");
console.log("AI summary tests passed.");
}
run().catch((error) => { console.error(error); process.exitCode = 1; });