238 lines
12 KiB
JavaScript
238 lines
12 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { loadBackground, plain, response } = require("./support/characterization.cjs");
|
|
const { loadApplication } = require("./support/application.cjs");
|
|
const TYPE = "opportunitiesExtension.requestWorkloads";
|
|
const CACHE = "opportunitiesExtension.workloadsCache.v1";
|
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
|
const request = (extra = {}) => ({ type: TYPE, customerId: "123", principal: "user@example.invalid",
|
|
authorization: { value: "synthetic-wwb-token" }, cacheMaxAgeMs: 3600000, ...extra });
|
|
const page = (ids, extra = {}) => ({ workloads: ids.map(id => ({ id, name: `Workload ${id}`, forecast: [] })), hasNext: false, ...extra });
|
|
|
|
for (const family of ["chrome", "firefox"]) {
|
|
test(`${family}: workloads paginate with response offset/limit and reuse persistent per-account cache`, async () => {
|
|
const calls = [], storage = {};
|
|
const h = loadBackground({ family, storage, fetch: async (url, options) => {
|
|
calls.push({ url: new URL(url), options });
|
|
const offset = Number(new URL(url).searchParams.get("offset"));
|
|
return response(offset === 0 ? page([11, 12], { hasNext: true, offset: 0, limit: 2 })
|
|
: offset === 2 ? page([13], { hasNext: true, offset: 2, limit: 1 }) : page([14]));
|
|
} });
|
|
const result = await h.dispatch(request());
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(result.workloads.map(item => item.id), [11, 12, 13, 14]);
|
|
assert.deepEqual(calls.map(call => call.url.searchParams.get("offset")), ["0", "2", "3"]);
|
|
for (const { url, options } of calls) {
|
|
assert.equal(url.origin, "https://spa.oracle.com");
|
|
assert.ok(url.pathname.endsWith("/customers/123/workloads"));
|
|
assert.equal(url.searchParams.get("workloadStatus"), "Pipeline,Implementation,Live");
|
|
assert.equal(url.searchParams.get("opportunityForecastType"), "All");
|
|
assert.equal(url.searchParams.get("includeForEstimates"), "ALL");
|
|
assert.equal(url.searchParams.get("financialYear"), "ROLLING_4_QTRS");
|
|
assert.equal(options.method, "GET");
|
|
assert.equal(options.credentials, "include");
|
|
assert.equal(options.headers["wwb-provider-authorization"], "synthetic-wwb-token");
|
|
}
|
|
assert.equal(result.debugRequests.length, 3);
|
|
assert.ok(result.debugRequests.every(entry => entry.name === "requestWorkloads"));
|
|
assert.equal(result.fromCache, false);
|
|
assert.equal((await h.dispatch(request())).fromCache, true);
|
|
assert.equal(calls.length, 3);
|
|
assert.doesNotMatch(JSON.stringify(storage), /synthetic-wwb-token|debugRequests|requestHeaders/);
|
|
const restarted = loadBackground({ family, storage });
|
|
const cached = await restarted.dispatch(request());
|
|
assert.equal(cached.fromCache, true);
|
|
assert.deepEqual(cached.workloads, result.workloads);
|
|
assert.equal(cached.cachedAt, result.cachedAt);
|
|
h.advance(3600001);
|
|
assert.equal((await h.dispatch(request())).fromCache, false);
|
|
assert.equal(calls.length, 6);
|
|
assert.equal((await h.dispatch(request({ customerId: "456" }))).fromCache, false);
|
|
assert.equal((await h.dispatch(request({ principal: "other@example.invalid" }))).fromCache, false);
|
|
assert.equal(calls.length, 12);
|
|
});
|
|
|
|
test(`${family}: workload cache coalesces flights and Refresh all prevents late cache restoration`, async () => {
|
|
const pending = [], storage = {};
|
|
const h = loadBackground({ family, storage, fetch: () => new Promise(resolve => pending.push(resolve)) });
|
|
const old = h.dispatch(request()), shared = h.dispatch(request());
|
|
await flush();
|
|
assert.equal(pending.length, 1);
|
|
assert.equal((await h.dispatch({ type: "opportunitiesExtension.invalidateProductsCache" })).ok, true);
|
|
const fresh = h.dispatch(request());
|
|
await flush();
|
|
assert.equal(pending.length, 2);
|
|
pending[1](response(page([22])));
|
|
await fresh;
|
|
pending[0](response(page([11])));
|
|
assert.deepEqual(await old, await shared);
|
|
const cached = await h.dispatch(request());
|
|
assert.equal(cached.fromCache, true);
|
|
assert.deepEqual(cached.workloads.map(item => item.id), [22]);
|
|
assert.equal(pending.length, 2);
|
|
});
|
|
|
|
test(`${family}: successful empty workloads are cached; storage failures do not lose results`, async () => {
|
|
let calls = 0;
|
|
const h = loadBackground({ family, fetch: async () => { calls++; return response(page([])); } });
|
|
assert.deepEqual((await h.dispatch(request())).workloads, []);
|
|
assert.equal((await h.dispatch(request())).fromCache, true);
|
|
assert.equal(calls, 1);
|
|
const unavailable = loadBackground({ family, storageError: true, fetch: async () => response(page([10])) });
|
|
assert.equal((await unavailable.dispatch(request())).ok, true);
|
|
});
|
|
}
|
|
|
|
test("workload failures and stalled pagination are not cached as complete results", async () => {
|
|
for (const bad of [response({}, 403), response({ workloads: [] }), response(page([], { hasNext: true })),
|
|
response(page([11], { hasNext: true, offset: 0, limit: 0 }))]) {
|
|
let calls = 0;
|
|
const h = loadBackground({ fetch: async () => ++calls === 1 ? bad : response(page([12])) });
|
|
const failed = await h.dispatch(request());
|
|
assert.equal(failed.ok, false);
|
|
assert.equal(failed.debugRequests.length, 1);
|
|
assert.equal((await h.dispatch(request())).ok, true);
|
|
assert.equal(calls, 2);
|
|
}
|
|
let calls = 0;
|
|
const repeated = loadBackground({ fetch: async () => { calls++; return response(page([11], { hasNext: true })); } });
|
|
assert.match((await repeated.dispatch(request())).error, /did not advance/);
|
|
assert.equal(calls, 2);
|
|
let attempt = 0;
|
|
const partial = loadBackground({ fetch: async () => ++attempt === 1
|
|
? response(page([11], { hasNext: true })) : response({}, 500) });
|
|
const failure = await partial.dispatch(request());
|
|
assert.equal(failure.ok, false);
|
|
assert.equal(failure.debugRequests.length, 2);
|
|
assert.equal(Object.keys(partial.storage[CACHE] || {}).length, 0);
|
|
});
|
|
|
|
test("workload validation rejects unsafe IDs or missing WWB tokens before requesting", async () => {
|
|
const h = loadBackground();
|
|
for (const extra of [{ customerId: "../123" }, { customerId: "" }, { authorization: {} }]) {
|
|
const result = await h.dispatch(request(extra));
|
|
assert.equal(result.ok, false);
|
|
assert.deepEqual(result.debugRequests, []);
|
|
}
|
|
});
|
|
|
|
test("workloads without a known principal are never persisted or reused across users", async () => {
|
|
const storage = {};
|
|
let calls = 0;
|
|
const h = loadBackground({ storage, fetch: async () => { calls++; return response(page([11])); } });
|
|
await h.dispatch(request({ principal: "" }));
|
|
await h.dispatch(request({ principal: "" }));
|
|
assert.equal(calls, 2);
|
|
assert.equal(storage[CACHE], undefined);
|
|
});
|
|
|
|
function content() {
|
|
let listener;
|
|
const app = loadApplication("content", {
|
|
document: { getElementById: () => null }, window: { requestAnimationFrame() {} },
|
|
chrome: { runtime: { onMessage: { addListener(fn) { listener = fn; } } } }
|
|
});
|
|
const calls = [], logs = [];
|
|
let tokens = 0;
|
|
app.currentUser.getUserId = () => "user@example.invalid";
|
|
app.authContentSession.requestConsumerTokenServiceOnce = async () => { tokens++; return { workloadActions: { value: "wwb" } }; };
|
|
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(...(entries || []));
|
|
app.platformContentMessages.sendRuntimeMessage = message => {
|
|
if (message.type !== TYPE) return Promise.resolve({ ok: true });
|
|
return new Promise(resolve => calls.push({ message: plain(message), resolve }));
|
|
};
|
|
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
|
|
const start = opportunities => {
|
|
app.productsContentJobs.requestOpptyProductsInBackground(opportunities, "fusion-token");
|
|
return app.productsContentState.currentProductsRequestId;
|
|
};
|
|
const progress = (id, number, items, fromCache = false) => listener({ type: "opportunitiesExtension.opptyProductsProgress",
|
|
requestId: id, optyNumber: number, items, fromCache, state: "running" });
|
|
return { app, calls, logs, start, progress, tokens: () => tokens };
|
|
}
|
|
const opportunity = (OptyNumber, PartyId) => ({ OptyNumber, CustomerAccount: { PartyId } });
|
|
|
|
for (const productsFirst of [true, false]) {
|
|
test(`accounts deduplicate independently of filters; products-first=${productsFirst} joins by account and RevnId`, async () => {
|
|
const h = content();
|
|
h.app.preferencesState.opptyProductsCacheHours = 12;
|
|
h.app.opportunitiesFilterState.tableSearch = "hides all opportunities";
|
|
const id = h.start([opportunity("A", 123), opportunity("B", "123"), opportunity("C", 456),
|
|
{ OptyNumber: "NO_ACCOUNT", PartyId: 999, Owner: { PartyId: 888 } }]);
|
|
await flush();
|
|
assert.deepEqual(h.calls.map(call => call.message.customerId), ["123", "456"]);
|
|
assert.deepEqual(plain(h.app.productsContentState.workloadCustomerIds), ["123", "456"]);
|
|
assert.equal(h.tokens(), 1);
|
|
assert.equal(h.calls[0].message.authorization.value, "wwb");
|
|
assert.equal(h.calls[0].message.cacheMaxAgeMs, 12 * 3600000);
|
|
assert.equal(h.calls[0].message.principal, "user@example.invalid");
|
|
const original = { RevnId: 11, Name: "Original product" };
|
|
function products() {
|
|
h.progress(id, "A", [original, { RevnId: "999" }]);
|
|
h.progress(id, "B", [{ RevnId: "12" }], true);
|
|
h.progress(id, "C", [{ RevnId: 11 }, { RevnId: 22 }]);
|
|
}
|
|
if (productsFirst) products();
|
|
h.calls[0].resolve({ ok: true, ...page([11, 12]), cachedAt: 100, fromCache: true, debugRequests: [{ name: "requestWorkloads" }] });
|
|
h.calls[1].resolve({ ok: true, ...page([22]), cachedAt: 200 });
|
|
await flush();
|
|
if (!productsFirst) products();
|
|
const results = h.app.productsContentState.opptyProductsByNumber;
|
|
assert.equal(results.get("A").items[0].workload.id, 11);
|
|
assert.equal(results.get("A").items[0].Name, "Original product");
|
|
assert.equal(original.workload, undefined, "do not mutate raw product responses or their cache");
|
|
assert.equal(results.get("A").items[1].workload, null);
|
|
assert.equal(results.get("B").items[0].workload.id, 12);
|
|
assert.equal(results.get("B").fromCache, true);
|
|
assert.equal(results.get("C").items[0].workload, null, "never join a workload from another account");
|
|
assert.equal(results.get("C").items[1].workload.id, 22);
|
|
assert.equal(h.app.productsContentState.workloadsByCustomer.get("123").fromCache, true);
|
|
assert.equal(h.logs.length, 1);
|
|
});
|
|
}
|
|
|
|
test("workload queue limits concurrency to four and ignores a superseded Reference", async () => {
|
|
const h = content();
|
|
h.start(Array.from({ length: 9 }, (_, i) => opportunity(`A${i}`, i + 1)));
|
|
await flush();
|
|
assert.equal(h.calls.length, 4);
|
|
h.app.opportunitiesQueryState.periodRequestVersion++;
|
|
const id = h.start([opportunity("NEW", 100)]);
|
|
await flush();
|
|
assert.equal(h.calls.length, 5);
|
|
for (const call of h.calls.slice(0, 4)) call.resolve({ ok: true, ...page([11]) });
|
|
await flush();
|
|
assert.equal(h.calls.length, 5, "do not continue accounts from the old Reference");
|
|
assert.deepEqual([...h.app.productsContentState.workloadsByCustomer.keys()], ["100"]);
|
|
h.calls[4].resolve({ ok: true, ...page([22]) });
|
|
await flush();
|
|
h.progress(id, "NEW", [{ RevnId: 22 }]);
|
|
assert.equal(h.app.productsContentState.opptyProductsByNumber.get("NEW").items[0].workload.id, 22);
|
|
});
|
|
|
|
test("one failed account does not block other accounts or discard products", async () => {
|
|
const h = content();
|
|
const id = h.start([opportunity("A", 123), opportunity("B", 456)]);
|
|
await flush();
|
|
h.progress(id, "A", [{ RevnId: 11 }]);
|
|
h.calls[0].resolve({ ok: false, error: "Synthetic failure", debugRequests: [{ name: "requestWorkloads", httpStatus: 403 }] });
|
|
h.calls[1].resolve({ ok: true, ...page([22]) });
|
|
await flush();
|
|
assert.equal(h.app.productsContentState.workloadsByCustomer.get("123").status, "error");
|
|
assert.equal(h.app.productsContentState.workloadsByCustomer.get("456").status, "ready");
|
|
assert.equal(h.app.productsContentState.opptyProductsByNumber.get("A").items.length, 1);
|
|
assert.equal(h.logs[0].httpStatus, 403);
|
|
});
|
|
|
|
test("no accounts skips authentication, and failed authentication settles workload states", async () => {
|
|
const h = content();
|
|
h.start([]);
|
|
await flush();
|
|
assert.equal(h.tokens(), 0);
|
|
h.app.authContentSession.requestConsumerTokenServiceOnce = async () => { throw new Error("Session unavailable"); };
|
|
h.start([opportunity("A", 123)]);
|
|
await flush();
|
|
assert.equal(h.calls.length, 0);
|
|
assert.equal(h.app.productsContentState.workloadsByCustomer.get("123").error, "Session unavailable");
|
|
});
|