diff --git a/dist/chromium/content.js b/dist/chromium/content.js index 5f2a429..9a62a62 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -2653,12 +2653,29 @@ function createFusionOpportunities({ environment }) { const { fetch } = environment; - async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page) { + async function requestOpportunitiesSavedSearch(token) { + const url = OPPORTUNITIES_QUERY_URL + "?access=userAdmin&applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=Opportunity&language=en&limit=-1&returnIdentifierOnlyFields=true"; + const headers = { Accept: "application/json", Authorization: `Bearer ${token}` }; + const entry = logRequestStart({ name: "requestOpportunitiesSavedSearch", method: "GET", url, headers }); + try { + const response = await fetch.call(environment.window, url, { method: "GET", credentials: "include", headers }); + logRequestSuccess(entry, response, await readResponseBodyForDebug(response)); + if (!response.ok) throw new Error(`requestOpportunitiesSavedSearch failed with status ${response.status}.`); + const data = await response.json(); + if (!Array.isArray(data?.items)) throw new Error("requestOpportunitiesSavedSearch returned an invalid response."); + const defaults = data.items.filter(item => item.isDefault === true); + if (defaults.length !== 1 || typeof defaults[0].savedSearchNumber !== "string" || !defaults[0].savedSearchNumber.trim()) throw new Error("A unique default Opportunity saved search with savedSearchNumber was not returned."); + return defaults[0].savedSearchNumber.trim(); + } catch (error) { logRequestFailure(entry, error.message); throw error; } + } + async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page, savedSearchNumber) { if (!isCurrentPeriodRequest(requestVersion)) { return null; } - const payload = createOpportunitiesQueryPayload(dateRange, offset); + if (!savedSearchNumber) savedSearchNumber = await requestOpportunitiesSavedSearch(token); + if (!isCurrentPeriodRequest(requestVersion)) return null; + const payload = createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber); const requestBody = JSON.stringify(payload); const requestEntry = logRequestStart({ name: "requestOpportunities", @@ -2682,7 +2699,7 @@ function createFusionOpportunities({ }); try { - const response = await fetch(OPPORTUNITIES_QUERY_URL, { + const response = await fetch.call(environment.window, OPPORTUNITIES_QUERY_URL, { method: "POST", credentials: "include", headers: { @@ -2726,7 +2743,8 @@ function createFusionOpportunities({ currentOffset + receivedCount, allItems, requestVersion, - page + 1 + page + 1, + savedSearchNumber ); } catch (error) { logRequestFailure(requestEntry, error.message || "Request failed."); @@ -2734,7 +2752,8 @@ function createFusionOpportunities({ } } - function createOpportunitiesQueryPayload(dateRange, offset) { + function createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber) { + if (typeof savedSearchNumber !== "string" || !savedSearchNumber.trim()) throw new Error("Missing default Opportunity savedSearchNumber."); return { aggregationResults: false, applications: ["ORACLE-ISS-APP"], @@ -2802,13 +2821,13 @@ function createFusionOpportunities({ language: "en", skipInValidFields: true, skipHiddenFromUIFields: true, - copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", + copiedFrom: "queries/" + savedSearchNumber, limit: OPPORTUNITIES_PAGE_LIMIT, offset }; } - return { requestOpportunitiesPage, createOpportunitiesQueryPayload }; + return { requestOpportunitiesPage, createOpportunitiesQueryPayload, requestOpportunitiesSavedSearch }; } // Source: src/services/fusion/crm-service-requests.js @@ -9847,7 +9866,7 @@ function createDebugContentView({ function getRequestCategory(entry) { if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; - if (entry.name === "requestOpportunities") return "opportunities"; + if (["requestOpportunities", "requestOpportunitiesSavedSearch"].includes(entry.name)) return "opportunities"; if (entry.name === "requestOpptyProducts") return "products"; if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions"; if (entry.name === "requestGenerateAiSummary") return "ai"; diff --git a/dist/firefox/content.js b/dist/firefox/content.js index 5f2a429..9a62a62 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -2653,12 +2653,29 @@ function createFusionOpportunities({ environment }) { const { fetch } = environment; - async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page) { + async function requestOpportunitiesSavedSearch(token) { + const url = OPPORTUNITIES_QUERY_URL + "?access=userAdmin&applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=Opportunity&language=en&limit=-1&returnIdentifierOnlyFields=true"; + const headers = { Accept: "application/json", Authorization: `Bearer ${token}` }; + const entry = logRequestStart({ name: "requestOpportunitiesSavedSearch", method: "GET", url, headers }); + try { + const response = await fetch.call(environment.window, url, { method: "GET", credentials: "include", headers }); + logRequestSuccess(entry, response, await readResponseBodyForDebug(response)); + if (!response.ok) throw new Error(`requestOpportunitiesSavedSearch failed with status ${response.status}.`); + const data = await response.json(); + if (!Array.isArray(data?.items)) throw new Error("requestOpportunitiesSavedSearch returned an invalid response."); + const defaults = data.items.filter(item => item.isDefault === true); + if (defaults.length !== 1 || typeof defaults[0].savedSearchNumber !== "string" || !defaults[0].savedSearchNumber.trim()) throw new Error("A unique default Opportunity saved search with savedSearchNumber was not returned."); + return defaults[0].savedSearchNumber.trim(); + } catch (error) { logRequestFailure(entry, error.message); throw error; } + } + async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page, savedSearchNumber) { if (!isCurrentPeriodRequest(requestVersion)) { return null; } - const payload = createOpportunitiesQueryPayload(dateRange, offset); + if (!savedSearchNumber) savedSearchNumber = await requestOpportunitiesSavedSearch(token); + if (!isCurrentPeriodRequest(requestVersion)) return null; + const payload = createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber); const requestBody = JSON.stringify(payload); const requestEntry = logRequestStart({ name: "requestOpportunities", @@ -2682,7 +2699,7 @@ function createFusionOpportunities({ }); try { - const response = await fetch(OPPORTUNITIES_QUERY_URL, { + const response = await fetch.call(environment.window, OPPORTUNITIES_QUERY_URL, { method: "POST", credentials: "include", headers: { @@ -2726,7 +2743,8 @@ function createFusionOpportunities({ currentOffset + receivedCount, allItems, requestVersion, - page + 1 + page + 1, + savedSearchNumber ); } catch (error) { logRequestFailure(requestEntry, error.message || "Request failed."); @@ -2734,7 +2752,8 @@ function createFusionOpportunities({ } } - function createOpportunitiesQueryPayload(dateRange, offset) { + function createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber) { + if (typeof savedSearchNumber !== "string" || !savedSearchNumber.trim()) throw new Error("Missing default Opportunity savedSearchNumber."); return { aggregationResults: false, applications: ["ORACLE-ISS-APP"], @@ -2802,13 +2821,13 @@ function createFusionOpportunities({ language: "en", skipInValidFields: true, skipHiddenFromUIFields: true, - copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", + copiedFrom: "queries/" + savedSearchNumber, limit: OPPORTUNITIES_PAGE_LIMIT, offset }; } - return { requestOpportunitiesPage, createOpportunitiesQueryPayload }; + return { requestOpportunitiesPage, createOpportunitiesQueryPayload, requestOpportunitiesSavedSearch }; } // Source: src/services/fusion/crm-service-requests.js @@ -9847,7 +9866,7 @@ function createDebugContentView({ function getRequestCategory(entry) { if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; - if (entry.name === "requestOpportunities") return "opportunities"; + if (["requestOpportunities", "requestOpportunitiesSavedSearch"].includes(entry.name)) return "opportunities"; if (entry.name === "requestOpptyProducts") return "products"; if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions"; if (entry.name === "requestGenerateAiSummary") return "ai"; diff --git a/src/features/debug/content-view.js b/src/features/debug/content-view.js index 376013a..3eeb2a5 100644 --- a/src/features/debug/content-view.js +++ b/src/features/debug/content-view.js @@ -21,7 +21,7 @@ function createDebugContentView({ function getRequestCategory(entry) { if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; - if (entry.name === "requestOpportunities") return "opportunities"; + if (["requestOpportunities", "requestOpportunitiesSavedSearch"].includes(entry.name)) return "opportunities"; if (entry.name === "requestOpptyProducts") return "products"; if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions"; if (entry.name === "requestGenerateAiSummary") return "ai"; diff --git a/src/services/fusion/opportunities.js b/src/services/fusion/opportunities.js index 1a619da..5398879 100644 --- a/src/services/fusion/opportunities.js +++ b/src/services/fusion/opportunities.js @@ -11,12 +11,29 @@ function createFusionOpportunities({ environment }) { const { fetch } = environment; - async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page) { + async function requestOpportunitiesSavedSearch(token) { + const url = OPPORTUNITIES_QUERY_URL + "?access=userAdmin&applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=Opportunity&language=en&limit=-1&returnIdentifierOnlyFields=true"; + const headers = { Accept: "application/json", Authorization: `Bearer ${token}` }; + const entry = logRequestStart({ name: "requestOpportunitiesSavedSearch", method: "GET", url, headers }); + try { + const response = await fetch.call(environment.window, url, { method: "GET", credentials: "include", headers }); + logRequestSuccess(entry, response, await readResponseBodyForDebug(response)); + if (!response.ok) throw new Error(`requestOpportunitiesSavedSearch failed with status ${response.status}.`); + const data = await response.json(); + if (!Array.isArray(data?.items)) throw new Error("requestOpportunitiesSavedSearch returned an invalid response."); + const defaults = data.items.filter(item => item.isDefault === true); + if (defaults.length !== 1 || typeof defaults[0].savedSearchNumber !== "string" || !defaults[0].savedSearchNumber.trim()) throw new Error("A unique default Opportunity saved search with savedSearchNumber was not returned."); + return defaults[0].savedSearchNumber.trim(); + } catch (error) { logRequestFailure(entry, error.message); throw error; } + } + async function requestOpportunitiesPage(token, period, dateRange, offset, accumulatedItems, requestVersion, page, savedSearchNumber) { if (!isCurrentPeriodRequest(requestVersion)) { return null; } - const payload = createOpportunitiesQueryPayload(dateRange, offset); + if (!savedSearchNumber) savedSearchNumber = await requestOpportunitiesSavedSearch(token); + if (!isCurrentPeriodRequest(requestVersion)) return null; + const payload = createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber); const requestBody = JSON.stringify(payload); const requestEntry = logRequestStart({ name: "requestOpportunities", @@ -40,7 +57,7 @@ function createFusionOpportunities({ }); try { - const response = await fetch(OPPORTUNITIES_QUERY_URL, { + const response = await fetch.call(environment.window, OPPORTUNITIES_QUERY_URL, { method: "POST", credentials: "include", headers: { @@ -84,7 +101,8 @@ function createFusionOpportunities({ currentOffset + receivedCount, allItems, requestVersion, - page + 1 + page + 1, + savedSearchNumber ); } catch (error) { logRequestFailure(requestEntry, error.message || "Request failed."); @@ -92,7 +110,8 @@ function createFusionOpportunities({ } } - function createOpportunitiesQueryPayload(dateRange, offset) { + function createOpportunitiesQueryPayload(dateRange, offset, savedSearchNumber) { + if (typeof savedSearchNumber !== "string" || !savedSearchNumber.trim()) throw new Error("Missing default Opportunity savedSearchNumber."); return { aggregationResults: false, applications: ["ORACLE-ISS-APP"], @@ -160,11 +179,11 @@ function createFusionOpportunities({ language: "en", skipInValidFields: true, skipHiddenFromUIFields: true, - copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", + copiedFrom: "queries/" + savedSearchNumber, limit: OPPORTUNITIES_PAGE_LIMIT, offset }; } - return { requestOpportunitiesPage, createOpportunitiesQueryPayload }; + return { requestOpportunitiesPage, createOpportunitiesQueryPayload, requestOpportunitiesSavedSearch }; } diff --git a/tests/characterization.test.cjs b/tests/characterization.test.cjs index d8c4211..190282f 100644 --- a/tests/characterization.test.cjs +++ b/tests/characterization.test.cjs @@ -105,7 +105,7 @@ test("fiscal dates preserve quarter/year boundaries, leap year and unsupported r startCloseDate: start + "T00:00:00.000Z", endCloseDate: end + "T00:00:00.000Z" }); const dateRange = h.getFiscalPeriodRange(period, new Date(...date)); - const criterion = h.createOpportunitiesQueryPayload(dateRange, 0).q.criteria[1]; + const criterion = h.createOpportunitiesQueryPayload(dateRange, 0, "default-search").q.criteria[1]; assert.equal(criterion.value1, start + "T00:00:00.000+0000"); assert.equal(criterion.value2, end + "T00:00:00.000+0000"); assert.equal(dateRange.endCloseDate, end + "T00:00:00.000Z", "payload must not mutate the shared date range"); @@ -118,7 +118,7 @@ test("opportunity query preserves fields, record-set OR, date AND, and paginatio const types = ["ORA_MYSALESTEAMOPTIES", "ORA_MYASSGTERROPTIES", "ORA_CREDITRECEIVER_ISME"]; h.setTypes(types); const fields = ["Name", "OptyNumber", "PrimaryRevenue.WinProb", "CustomerAccount", "PrimaryRevenue.RevnAmount", "EffectiveDate", "StatusCode", "DealRisk_c", "Owner", "ForecastGroup_c", "LastUpdateDate", "PrimaryRevenue.RevnAmountCurcyCode", "OptyId"]; - assert.deepEqual(plain(h.createOpportunitiesQueryPayload(range, 15)), { + assert.deepEqual(plain(h.createOpportunitiesQueryPayload(range, 15, "default-search")), { aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "Opportunity", q: { op: "$and", criteria: [ { op: "$and", criteria: [{ op: "$or", criteria: types.map(value => ({ op: "$eq", attribute: "RecordSet", value })) }] }, @@ -126,13 +126,14 @@ test("opportunity query preserves fields, record-set OR, date AND, and paginatio ] }, keywords: null, keywordsFields: fields, fields: [...fields, "CustomerAccount.PartyUniqueName", "CustomerAccount.PartyId", "CustomerAccount.PartyNumber", "CustomerAccount.Region_c", "CustomerAccount.PrimaryAddress.CountryCode"], sort: null, language: "en", skipInValidFields: true, skipHiddenFromUIFields: true, - copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", limit: 15, offset: 15 + copiedFrom: "queries/default-search", limit: 15, offset: 15 }); }); test("opportunity pagination uses response offsets/count, ignores stale versions and detects stalled pages", async () => { const calls = []; const h = loadContent({ fetch: async (url, options) => { + if (options.method === "GET") return response({ items: [{ isDefault: false, savedSearchNumber: "other" }, { isDefault: true, savedSearchNumber: "user-default" }] }); calls.push({ url, options }); return response(calls.length === 1 ? { items: [{ OptyNumber: "S1" }], offset: 0, count: 1, hasMore: true } @@ -141,6 +142,7 @@ test("opportunity pagination uses response offsets/count, ignores stale versions const items = await h.requestOpportunitiesPage("synthetic-token", "Current Quarter", range, 0, [], 0, 1); assert.deepEqual(plain(items), [{ OptyNumber: "S1" }, { OptyNumber: "S2" }]); assert.deepEqual(calls.map(call => JSON.parse(call.options.body).offset), [0, 1]); + assert.deepEqual(calls.map(call => JSON.parse(call.options.body).copiedFrom), ["queries/user-default", "queries/user-default"]); assert.equal(calls[0].url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/11.13.18.05/custom-actions/queries"); assert.deepEqual(plain(calls[0].options.headers), { Accept: "application/json", Authorization: "Bearer synthetic-token", "Content-Type": "application/json", @@ -151,10 +153,39 @@ test("opportunity pagination uses response offsets/count, ignores stale versions h.setVersion(1); assert.equal(await h.requestOpportunitiesPage("unused", "Current Quarter", range, 0, [], 0, 1), null); assert.equal(calls.length, 2); - const stalled = loadContent({ fetch: async () => response({ items: [], count: 0, hasMore: true }) }); + const stalled = loadContent({ fetch: async (_, options) => response(options.method === "GET" ? { items: [{ isDefault: true, savedSearchNumber: "default" }] } : { items: [], count: 0, hasMore: true }) }); await assert.rejects(stalled.requestOpportunitiesPage("synthetic", "Current Quarter", range, 0, [], 0, 1), /without additional results/); }); +test("saved search GET precedes all opportunity pages and supplies the user's default", async () => { + const calls = []; + const h = loadContent({ fetch: async (url, options) => { + calls.push({url,options}); + if (options.method === "GET") return response({items:[{isDefault:false,savedSearchNumber:"not-default"},{isDefault:true,savedSearchNumber:"selected-default"}]}); + const offset = JSON.parse(options.body).offset; + return response({items:[{OptyNumber:String(offset)}],offset,count:1,hasMore:offset===0}); + }}); + await h.requestOpportunitiesPage("relay-token","Current Quarter",range,0,[],0,1); + assert.deepEqual(calls.map(call=>call.options.method),["GET","POST","POST"]); + const url = new URL(calls[0].url); + assert.deepEqual(Object.fromEntries(url.searchParams),{access:"userAdmin",applications:"461e5791-9f15-4cd1-b5cd-19e8480cbce4",entity:"Opportunity",language:"en",limit:"-1",returnIdentifierOnlyFields:"true"}); + assert.equal(calls[0].options.headers.Authorization,"Bearer relay-token"); + for (const call of calls.slice(1)) assert.equal(JSON.parse(call.options.body).copiedFrom,"queries/selected-default"); +}); + +test("missing or failed default search blocks opportunities POST and stale lookup is ignored", async () => { + for (const data of [{items:[]},{items:[{isDefault:true}]},{items:[{isDefault:true,savedSearchNumber:"a"},{isDefault:true,savedSearchNumber:"b"}]},null]) { + const calls=[]; + const h=loadContent({fetch:async(_,options)=>{calls.push(options.method);return response(data,data===null?403:200);}}); + await assert.rejects(h.requestOpportunitiesPage("token","Current Quarter",range,0,[],0,1)); + assert.deepEqual(calls,["GET"]); + assert.equal(h.requestLog.at(-1).name,"requestOpportunitiesSavedSearch"); + assert.equal(h.requestLog.at(-1).status,"failed"); + } + const h=loadContent({fetch:async()=>{h.setVersion(1);return response({items:[{isDefault:true,savedSearchNumber:"default"}]});}}); + assert.equal(await h.requestOpportunitiesPage("token","Current Quarter",range,0,[],0,1),null); +}); + const opportunity = (number, status, amount) => ({ Name: `Synthetic cloud ${number}`, OptyNumber: number, StatusCode: status, ForecastGroup_c: "PIPELINE", CustomerAccount: { PartyId: "customer-1", PartyUniqueName: "Synthetic Customer", PartyNumber: "party-1" }, diff --git a/tests/debugTabs.test.cjs b/tests/debugTabs.test.cjs index ac59b95..2a1e79a 100644 --- a/tests/debugTabs.test.cjs +++ b/tests/debugTabs.test.cjs @@ -89,7 +89,7 @@ test("request categories cover every current family and unknown requests stay vi for (const [name, category] of Object.entries({ requestTokenRelay: "authentication", requestTokenRelaySalesCommon: "authentication", requestConsumerTokenService: "authentication", requestCustomerTokenService: "authentication", - requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai", + requestOpportunities: "opportunities", requestOpportunitiesSavedSearch: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai", requestAccountSummary: "consumption", requestAccountPlans: "consumption", requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests", requestSRInfo: "serviceRequests", requestConsumptionPlanDetail: "consumption", diff --git a/tests/fixtures/extension.js b/tests/fixtures/extension.js index 5dc4938..d9a0013 100644 --- a/tests/fixtures/extension.js +++ b/tests/fixtures/extension.js @@ -98,6 +98,7 @@ let body; if (url.pathname === "/fscmRestApi/tokenrelay") body = { access_token: "synthetic-fusion-token", expires_in: 3600 }; else if (url.pathname.endsWith("custom-actions/queries")) { + if (options.method === "GET") return Response.json({ items: [{ isDefault: true, savedSearchNumber: "fixture-default" }] }); const { offset, limit, entity } = JSON.parse(options.body); if (entity === "CRMServiceRequest") return new Response(JSON.stringify({ items: [{ SrId: "123456", SrNumber: "SR123456", Title: "Example parent SR", PrimaryPillarNew_c: "Tech_Cloud_Infrastructure", CustomerDeadline_c: "2026-11-30T00:00:00.000+0000", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "TEST1" } }], hasMore: false }), { status: 200 }); body = { items: opportunities.slice(offset, offset + limit), totalResults: opportunities.length, count: Math.min(limit, opportunities.length - offset), hasMore: offset + limit < opportunities.length, offset, limit };