diff options
Diffstat (limited to 'toolkit/components/shopping')
58 files changed, 4659 insertions, 0 deletions
diff --git a/toolkit/components/shopping/content/ProductConfig.mjs b/toolkit/components/shopping/content/ProductConfig.mjs new file mode 100644 index 0000000000..ddbb5317af --- /dev/null +++ b/toolkit/components/shopping/content/ProductConfig.mjs @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +let { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const ANALYSIS_RESPONSE_SCHEMA = + "chrome://global/content/shopping/analysis_response.schema.json"; +const ANALYSIS_REQUEST_SCHEMA = + "chrome://global/content/shopping/analysis_request.schema.json"; + +const ANALYZE_RESPONSE_SCHEMA = + "chrome://global/content/shopping/analyze_response.schema.json"; +const ANALYZE_REQUEST_SCHEMA = + "chrome://global/content/shopping/analyze_request.schema.json"; + +const ANALYSIS_STATUS_RESPONSE_SCHEMA = + "chrome://global/content/shopping/analysis_status_response.schema.json"; +const ANALYSIS_STATUS_REQUEST_SCHEMA = + "chrome://global/content/shopping/analysis_status_request.schema.json"; + +const RECOMMENDATIONS_RESPONSE_SCHEMA = + "chrome://global/content/shopping/recommendations_response.schema.json"; +const RECOMMENDATIONS_REQUEST_SCHEMA = + "chrome://global/content/shopping/recommendations_request.schema.json"; + +const ATTRIBUTION_RESPONSE_SCHEMA = + "chrome://global/content/shopping/attribution_response.schema.json"; +const ATTRIBUTION_REQUEST_SCHEMA = + "chrome://global/content/shopping/attribution_request.schema.json"; + +const REPORTING_RESPONSE_SCHEMA = + "chrome://global/content/shopping/reporting_response.schema.json"; +const REPORTING_REQUEST_SCHEMA = + "chrome://global/content/shopping/reporting_request.schema.json"; + +// Allow switching to the stage or test environments by changing the +// "toolkit.shopping.environment" pref from "prod" to "stage" or "test". +const lazy = {}; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "env", + "toolkit.shopping.environment", + "prod", + null, + // If the pref is set to an unexpected string value, "prod" will be used. + prefValue => + ["prod", "stage", "test"].includes(prefValue) ? prefValue : "prod" +); + +// prettier-ignore +const Environments = { + prod: { + ANALYSIS_API: "https://trustwerty.com/api/v2/fx/analysis", + ANALYSIS_STATUS_API: "https://trustwerty.com/api/v1/fx/analysis_status", + ANALYZE_API: "https://trustwerty.com/api/v1/fx/analyze", + ATTRIBUTION_API: "https://pe.fakespot.com/api/v1/fx/events", + RECOMMENDATIONS_API: "https://a.fakespot.com/v1/fx/sp_search", + REPORTING_API: "https://trustwerty.com/api/v1/fx/report", + }, + stage: { + ANALYSIS_API: "https://staging.trustwerty.com/api/v2/fx/analysis", + ANALYSIS_STATUS_API: "https://staging.trustwerty.com/api/v1/fx/analysis_status", + ANALYZE_API: "https://staging.trustwerty.com/api/v1/fx/analyze", + ATTRIBUTION_API: "https://staging-partner-ads.fakespot.io/api/v1/fx/events", + RECOMMENDATIONS_API: "https://staging-affiliates.fakespot.io/v1/fx/sp_search", + REPORTING_API: "https://staging.trustwerty.com/api/v1/fx/report", + }, + test: { + ANALYSIS_API: "https://example.com/browser/toolkit/components/shopping/test/browser/analysis.sjs", + ANALYSIS_STATUS_API: "https://example.com/browser/toolkit/components/shopping/test/browser/analysis_status.sjs", + ANALYZE_API: "https://example.com/browser/toolkit/components/shopping/test/browser/analyze.sjs", + ATTRIBUTION_API: "https://example.com/browser/toolkit/components/shopping/test/browser/attribution.sjs", + RECOMMENDATIONS_API: "https://example.com/browser/toolkit/components/shopping/test/browser/recommendations.sjs", + REPORTING_API: "https://example.com/browser/toolkit/components/shopping/test/browser/reporting.sjs", + }, +}; + +const ShoppingEnvironment = { + get ANALYSIS_API() { + return Environments[lazy.env].ANALYSIS_API; + }, + get ANALYSIS_STATUS_API() { + return Environments[lazy.env].ANALYSIS_STATUS_API; + }, + get ANALYZE_API() { + return Environments[lazy.env].ANALYZE_API; + }, + get ATTRIBUTION_API() { + return Environments[lazy.env].ATTRIBUTION_API; + }, + get RECOMMENDATIONS_API() { + return Environments[lazy.env].RECOMMENDATIONS_API; + }, + get REPORTING_API() { + return Environments[lazy.env].REPORTING_API; + }, +}; + +const ProductConfig = { + amazon: { + productIdFromURLRegex: + /(?:[\/]|$|%2F)(?<productId>[A-Z0-9]{10})(?:[\/]|$|\#|\?|%2F)/, + validTLDs: ["com", "de", "fr"], + }, + walmart: { + productIdFromURLRegex: + /\/ip\/(?:[A-Za-z0-9-]{1,320}\/)?(?<productId>[0-9]{3,13})/, + validTLDs: ["com"], + }, + bestbuy: { + productIdFromURLRegex: /\/(?<productId>\d+\.p)/, + validTLDs: ["com"], + }, +}; + +if (lazy.env == "test") { + // Also allow example.com to allow for testing. + ProductConfig.example = ProductConfig.amazon; +} + +export { + ANALYSIS_RESPONSE_SCHEMA, + ANALYSIS_REQUEST_SCHEMA, + ANALYZE_RESPONSE_SCHEMA, + ANALYZE_REQUEST_SCHEMA, + ANALYSIS_STATUS_RESPONSE_SCHEMA, + ANALYSIS_STATUS_REQUEST_SCHEMA, + RECOMMENDATIONS_RESPONSE_SCHEMA, + RECOMMENDATIONS_REQUEST_SCHEMA, + ATTRIBUTION_RESPONSE_SCHEMA, + ATTRIBUTION_REQUEST_SCHEMA, + REPORTING_RESPONSE_SCHEMA, + REPORTING_REQUEST_SCHEMA, + ProductConfig, + ShoppingEnvironment, +}; diff --git a/toolkit/components/shopping/content/ProductValidator.sys.mjs b/toolkit/components/shopping/content/ProductValidator.sys.mjs new file mode 100644 index 0000000000..113093151a --- /dev/null +++ b/toolkit/components/shopping/content/ProductValidator.sys.mjs @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { JsonSchema } from "resource://gre/modules/JsonSchema.sys.mjs"; + +let schemas = {}; + +/** + * Validate JSON result from the shopping API. + * + * @param {object} json + * JSON object from the API request. + * @param {string} SchemaURL + * URL string for the schema to validate with. + * @param {boolean} logErrors + * Should invalid JSON log out the errors. + * @returns {boolean} result + * If the JSON is valid or not. + */ +async function validate(json, SchemaURL, logErrors) { + if (!schemas[SchemaURL]) { + schemas[SchemaURL] = await fetch(SchemaURL).then(rsp => rsp.json()); + } + + let result = JsonSchema.validate(json, schemas[SchemaURL]); + let { errors } = result; + if (!result.valid && logErrors) { + console.error(`Invalid result: ${JSON.stringify(errors, undefined, 2)}`); + } + return result.valid; +} + +export const ProductValidator = { + validate, +}; diff --git a/toolkit/components/shopping/content/ShoppingProduct.mjs b/toolkit/components/shopping/content/ShoppingProduct.mjs new file mode 100644 index 0000000000..d457ac1579 --- /dev/null +++ b/toolkit/components/shopping/content/ShoppingProduct.mjs @@ -0,0 +1,962 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + ANALYSIS_RESPONSE_SCHEMA, + ANALYSIS_REQUEST_SCHEMA, + ANALYZE_RESPONSE_SCHEMA, + ANALYZE_REQUEST_SCHEMA, + ANALYSIS_STATUS_RESPONSE_SCHEMA, + ANALYSIS_STATUS_REQUEST_SCHEMA, + RECOMMENDATIONS_RESPONSE_SCHEMA, + RECOMMENDATIONS_REQUEST_SCHEMA, + ATTRIBUTION_RESPONSE_SCHEMA, + ATTRIBUTION_REQUEST_SCHEMA, + REPORTING_RESPONSE_SCHEMA, + REPORTING_REQUEST_SCHEMA, + ProductConfig, + ShoppingEnvironment, +} from "chrome://global/content/shopping/ProductConfig.mjs"; + +let { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +let { EventEmitter } = ChromeUtils.importESModule( + "resource://gre/modules/EventEmitter.sys.mjs" +); + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + HPKEConfigManager: "resource://gre/modules/HPKEConfigManager.sys.mjs", + ProductValidator: "chrome://global/content/shopping/ProductValidator.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const API_RETRIES = 3; +const API_RETRY_TIMEOUT = 100; +const API_POLL_ATTEMPTS = 260; +const API_POLL_INITIAL_WAIT = 1000; +const API_POLL_WAIT = 1000; + +XPCOMUtils.defineLazyServiceGetters(lazy, { + ohttpService: [ + "@mozilla.org/network/oblivious-http-service;1", + Ci.nsIObliviousHttpService, + ], +}); + +ChromeUtils.defineLazyGetter(lazy, "decoder", () => new TextDecoder()); + +const StringInputStream = Components.Constructor( + "@mozilla.org/io/string-input-stream;1", + "nsIStringInputStream", + "setData" +); + +const BinaryInputStream = Components.Constructor( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +function readFromStream(stream, count) { + let binaryStream = new BinaryInputStream(stream); + let arrayBuffer = new ArrayBuffer(count); + while (count > 0) { + let actuallyRead = binaryStream.readArrayBuffer(count, arrayBuffer); + if (!actuallyRead) { + throw new Error("Nothing read from input stream!"); + } + count -= actuallyRead; + } + return arrayBuffer; +} + +/** + * @typedef {object} Product + * A parsed product for a URL + * @property {number} id + * The product id of the product. + * @property {string} host + * The host of a product url (without www) + * @property {string} tld + * The top level domain of a URL + * @property {string} sitename + * The name of a website (without TLD or subdomains) + * @property {boolean} valid + * If the product is valid or not + */ + +/** + * Class for working with the products shopping API, + * with helpers for parsing the product from a url + * and querying the shopping API for information on it. + * + * @example + * let product = new ShoppingProduct(url); + * if (product.isProduct()) { + * let analysis = await product.requestAnalysis(); + * let recommendations = await product.requestRecommendations(); + * } + * @example + * if (!isProductURL(url)) { + * return; + * } + * let product = new ShoppingProduct(url); + * let analysis = await product.requestAnalysis(); + * let recommendations = await product.requestRecommendations(); + */ +export class ShoppingProduct extends EventEmitter { + /** + * Creates a product. + * + * @param {URL} url + * URL to get the product info from. + * @param {object} [options] + * @param {boolean} [options.allowValidationFailure=true] + * Should validation failures be allowed or return null + */ + constructor(url, options = { allowValidationFailure: true }) { + super(); + + this.allowValidationFailure = !!options.allowValidationFailure; + + this._abortController = new AbortController(); + + if (url instanceof Ci.nsIURI) { + url = URL.fromURI(url); + } + + if (url && URL.isInstance(url)) { + let product = this.constructor.fromURL(url); + this.assignProduct(product); + } + } + + /** + * Gets a Product from a URL. + * + * @param {URL} url + * URL to find a product in. + * @returns {Product} + */ + static fromURL(url) { + let host, sitename, tld; + let result = { valid: false }; + + if (!url || !URL.isInstance(url)) { + return result; + } + + // Lowercase hostname and remove www. + host = url.hostname.replace(/^www\./i, ""); + result.host = host; + + // Get host TLD + try { + tld = Services.eTLD.getPublicSuffixFromHost(host); + } catch (_) { + return result; + } + if (!tld.length) { + return result; + } + + // Remove tld and the preceding period to get the sitename + sitename = host.slice(0, -(tld.length + 1)); + + // Check if sitename is one the API has products for + let siteConfig = ProductConfig[sitename]; + if (!siteConfig) { + return result; + } + result.sitename = sitename; + + // Check if API has products for this TLD + if (!siteConfig.validTLDs.includes(tld)) { + return result; + } + result.tld = tld; + + // Try to find a product id from the pathname. + let found = url.pathname.match(siteConfig.productIdFromURLRegex); + if (!found?.groups) { + return result; + } + + let { productId } = found.groups; + if (!productId) { + return result; + } + result.id = productId; + + // Mark product as valid and complete. + result.valid = true; + + return result; + } + + /** + * Check if a Product is a valid product. + * + * @param {Product} product + * Product to check. + * @returns {boolean} + */ + static isProduct(product) { + return !!( + product && + product.valid && + product.id && + product.host && + product.sitename && + product.tld + ); + } + + /** + * Check if a the instances product is a valid product. + * + * @returns {boolean} + */ + isProduct() { + return this.constructor.isProduct(this.product); + } + + /** + * Assign a product to this instance. + */ + assignProduct(product) { + if (this.constructor.isProduct(product)) { + this.product = product; + } + } + + /** + * Request analysis for a product from the API. + * + * @param {Product} product + * Product to request for (defaults to the instances product). + * @param {object} options + * Override default API url and schema. + * @returns {object} result + * Parsed JSON API result or null. + */ + async requestAnalysis( + product = this.product, + options = { + url: ShoppingEnvironment.ANALYSIS_API, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + } + ) { + if (!product) { + return null; + } + + let requestOptions = { + product_id: product.id, + website: product.host, + }; + + let { url, requestSchema, responseSchema } = options; + let { allowValidationFailure } = this; + + let result = await ShoppingProduct.request(url, requestOptions, { + requestSchema, + responseSchema, + allowValidationFailure, + }); + + return result; + } + + /** + * Request recommended related products from the API. + * Currently only provides recommendations for Amazon products, + * which may be paid ads. + * + * @param {Product} product + * Product to request for (defaults to the instances product). + * @param {object} options + * Override default API url and schema. + * @returns {object} result + * Parsed JSON API result or null. + */ + async requestRecommendations( + product = this.product, + options = { + url: ShoppingEnvironment.RECOMMENDATIONS_API, + requestSchema: RECOMMENDATIONS_REQUEST_SCHEMA, + responseSchema: RECOMMENDATIONS_RESPONSE_SCHEMA, + } + ) { + if (!product) { + return null; + } + + let requestOptions = { + product_id: product.id, + website: product.host, + }; + let { url, requestSchema, responseSchema } = options; + let { allowValidationFailure } = this; + let result = await ShoppingProduct.request(url, requestOptions, { + requestSchema, + responseSchema, + allowValidationFailure, + }); + + for (let ad of result) { + ad.image_blob = await ShoppingProduct.requestImageBlob(ad.image_url, { + signal: this._abortController.signal, + }); + } + + return result; + } + + /** + * Request method for shopping API. + * + * @param {string} apiURL + * URL string for the API to request. + * @param {object} bodyObj + * What to send to the API. + * @param {object} [options] + * Options for validation and retries. + * @param {string} [options.requestSchema] + * URL string for the JSON Schema to validated the request. + * @param {string} [options.responseSchema] + * URL string for the JSON Schema to validated the response. + * @param {int} [options.failCount] + * Current number of failures for this request. + * @param {int} [options.maxRetries=API_RETRIES] + * Max number of allowed failures. + * @param {int} [options.retryTimeout=API_RETRY_TIMEOUT] + * Minimum time to wait. + * @param {AbortSignal} [options.signal] + * Signal to check if the request needs to be aborted. + * @param {boolean} [options.allowValidationFailure=true] + * Should validation failures be allowed. + * @returns {object} result + * Parsed JSON API result or null. + */ + static async request(apiURL, bodyObj = {}, options = {}) { + let { + requestSchema, + responseSchema, + failCount = 0, + maxRetries = API_RETRIES, + retryTimeout = API_RETRY_TIMEOUT, + signal = new AbortController().signal, + allowValidationFailure = true, + } = options; + + if (signal.aborted) { + return null; + } + + if (bodyObj && requestSchema) { + let validRequest = await lazy.ProductValidator.validate( + bodyObj, + requestSchema, + allowValidationFailure + ); + if (!validRequest) { + Glean?.shoppingProduct?.invalidRequest.record(); + if (!allowValidationFailure) { + return null; + } + } + } + + let requestOptions = { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(bodyObj), + signal, + }; + + let requestPromise; + let ohttpRelayURL = Services.prefs.getStringPref( + "toolkit.shopping.ohttpRelayURL", + "" + ); + let ohttpConfigURL = Services.prefs.getStringPref( + "toolkit.shopping.ohttpConfigURL", + "" + ); + if (ohttpRelayURL && ohttpConfigURL) { + let config = await ShoppingProduct.getOHTTPConfig(ohttpConfigURL); + // In the time it took to fetch the OHTTP config, we might have been + // aborted... + if (signal.aborted) { + Glean?.shoppingProduct?.requestAborted.record(); + return null; + } + if (!config) { + Glean?.shoppingProduct?.invalidOhttpConfig.record(); + console.error( + new Error( + "OHTTP was configured for shopping but we couldn't get a valid config." + ) + ); + return null; + } + requestPromise = ShoppingProduct.ohttpRequest( + ohttpRelayURL, + config, + apiURL, + requestOptions + ); + } else { + requestPromise = fetch(apiURL, requestOptions); + } + + let result; + let responseOk; + let responseStatus; + try { + let response = await requestPromise; + responseOk = response.ok; + responseStatus = response.status; + result = await response.json(); + + if (responseOk && responseSchema) { + let validResponse = await lazy.ProductValidator.validate( + result, + responseSchema, + allowValidationFailure + ); + if (!validResponse) { + Glean?.shoppingProduct?.invalidResponse.record(); + if (!allowValidationFailure) { + return null; + } + } + } + } catch (error) { + Glean?.shoppingProduct?.requestError.record(); + console.error(error); + } + + if (!responseOk && responseStatus < 500) { + Glean?.shoppingProduct?.requestFailure.record(); + } + + // Retry 500 errors. + if (!responseOk && responseStatus >= 500) { + failCount++; + + Glean?.shoppingProduct?.serverFailure.record(); + + // Make sure we still want to retry + if (failCount > maxRetries) { + Glean?.shoppingProduct?.requestRetriesFailed.record(); + return null; + } + + Glean?.shoppingProduct?.requestRetried.record(); + // Wait for a back off timeout base on the number of failures. + let backOff = retryTimeout * Math.pow(2, failCount - 1); + + await new Promise(resolve => lazy.setTimeout(resolve, backOff)); + + // Try the request again. + options.failCount = failCount; + result = await ShoppingProduct.request(apiURL, bodyObj, options); + } + + return result; + } + + /** + * Get a cached, or fetch a copy of, an OHTTP config from a given URL. + * + * + * @param {string} gatewayConfigURL + * The URL for the config that needs to be fetched. + * The URL should be complete (i.e. include the full path to the config). + * @returns {Uint8Array} + * The config bytes. + */ + static async getOHTTPConfig(gatewayConfigURL) { + return lazy.HPKEConfigManager.get(gatewayConfigURL); + } + + /** + * Make a request over OHTTP. + * + * @param {string} obliviousHTTPRelay + * The URL of the OHTTP relay to use. + * @param {Uint8Array} config + * A byte array representing the OHTTP config. + * @param {string} requestURL + * The URL of the request we want to make over the relay. + * @param {object} options + * @param {string} options.method + * The HTTP method to use for the inner request. Only GET and POST + * are supported right now. + * @param {string} options.body + * The body content to send over the request. + * @param {object} options.headers + * The request headers to set. Each property of the object represents + * a header, with the key the header name and the value the header value. + * @param {AbortSignal} options.signal + * If the consumer passes an AbortSignal object, aborting the signal + * will abort the request. + * + * @returns {object} + * Returns an object with properties mimicking that of a normal fetch(): + * .ok = boolean indicating whether the request was successful. + * .status = integer representation of the HTTP status code + * .headers = object representing the response headers. + * .json() = method that returns the parsed JSON response body. + */ + static async ohttpRequest( + obliviousHTTPRelay, + config, + requestURL, + { method, body, headers, signal } = {} + ) { + let relayURI = Services.io.newURI(obliviousHTTPRelay); + let requestURI = Services.io.newURI(requestURL); + let obliviousHttpChannel = lazy.ohttpService + .newChannel(relayURI, requestURI, config) + .QueryInterface(Ci.nsIHttpChannel); + + if (method == "POST") { + let uploadChannel = obliviousHttpChannel.QueryInterface( + Ci.nsIUploadChannel2 + ); + let bodyStream = new StringInputStream(body, body.length); + uploadChannel.explicitSetUploadStream( + bodyStream, + null, + -1, + "POST", + false + ); + } + + for (let headerName of Object.keys(headers)) { + obliviousHttpChannel.setRequestHeader( + headerName, + headers[headerName], + false + ); + } + let abortHandler = e => { + Glean?.shoppingProduct?.requestAborted.record(); + obliviousHttpChannel.cancel(Cr.NS_BINDING_ABORTED); + }; + signal.addEventListener("abort", abortHandler); + return new Promise((resolve, reject) => { + let listener = { + _buffer: [], + _headers: null, + QueryInterface: ChromeUtils.generateQI([ + "nsIStreamListener", + "nsIRequestObserver", + ]), + onStartRequest(request) { + this._headers = {}; + try { + request + .QueryInterface(Ci.nsIHttpChannel) + .visitResponseHeaders((header, value) => { + this._headers[header.toLowerCase()] = value; + }); + } catch (error) { + this._headers = null; + } + }, + onDataAvailable(request, stream, offset, count) { + this._buffer.push(readFromStream(stream, count)); + }, + onStopRequest(request, requestStatus) { + signal.removeEventListener("abort", abortHandler); + let result = this._buffer; + try { + let ohttpStatus = request.QueryInterface(Ci.nsIObliviousHttpChannel) + .relayChannel.responseStatus; + if (ohttpStatus == 200) { + let httpStatus = request.QueryInterface( + Ci.nsIHttpChannel + ).responseStatus; + resolve({ + ok: requestStatus == Cr.NS_OK && httpStatus == 200, + status: httpStatus, + headers: this._headers, + json() { + let decodedBuffer = result.reduce((accumulator, currVal) => { + return accumulator + lazy.decoder.decode(currVal); + }, ""); + return JSON.parse(decodedBuffer); + }, + blob() { + return new Blob(result, { type: "image/jpeg" }); + }, + }); + } else { + resolve({ + ok: false, + status: ohttpStatus, + json() { + return null; + }, + blob() { + return null; + }, + }); + } + } catch (error) { + reject(error); + } + }, + }; + obliviousHttpChannel.asyncOpen(listener); + }); + } + + /** + * Requests an image for a recommended product. + * + * @param {string} imageUrl + * @returns {blob} A blob of the image + */ + static async requestImageBlob(imageUrl, options = {}) { + let { signal = new AbortController().signal } = options; + let ohttpRelayURL = Services.prefs.getStringPref( + "toolkit.shopping.ohttpRelayURL", + "" + ); + let ohttpConfigURL = Services.prefs.getStringPref( + "toolkit.shopping.ohttpConfigURL", + "" + ); + + let imgRequestPromise; + if (ohttpRelayURL && ohttpConfigURL) { + let config = await ShoppingProduct.getOHTTPConfig(ohttpConfigURL); + if (!config) { + Glean?.shoppingProduct?.invalidOhttpConfig.record(); + console.error( + new Error( + "OHTTP was configured for shopping but we couldn't get a valid config." + ) + ); + return null; + } + + let imgRequestOptions = { + signal, + headers: { + Accept: "image/jpeg", + "Content-Type": "image/jpeg", + }, + }; + + imgRequestPromise = ShoppingProduct.ohttpRequest( + ohttpRelayURL, + config, + imageUrl, + imgRequestOptions + ); + } else { + imgRequestPromise = fetch(imageUrl); + } + + let imgResult; + try { + let response = await imgRequestPromise; + imgResult = await response.blob(); + } catch (error) { + console.error(error); + } + + return imgResult; + } + + /** + * Poll Analysis Status API until an analysis has finished. + * + * After an initial wait keep checking the api for results, + * until we have reached a maximum of tries. + * + * Passes all arguments to requestAnalysisCreationStatus(). + * + * @example + * let analysis; + * let { status } = await product.pollForAnalysisCompleted(); + * // Check if analysis has finished + * if(status != "pending" && status != "in_progress") { + * // Get the new analysis + * analysis = await product.requestAnalysis(); + * } + * + * @example + * // Check the current status + * let { status } = await product.requestAnalysisCreationStatus(); + * if(status == "pending" && status == "in_progress") { + * // Start polling without the initial timeout if the analysis + * // is already in progress. + * await product.pollForAnalysisCompleted({ + * pollInitialWait: analysisStatus == "in_progress" ? 0 : undefined, + * }); + * } + * @param {object} options + * Override default API url and schema. + * @returns {object} result + * Parsed JSON API result or null. + */ + async pollForAnalysisCompleted(options) { + let pollCount = 0; + let initialWait = options?.pollInitialWait || API_POLL_INITIAL_WAIT; + let pollTimeout = options?.pollTimeout || API_POLL_WAIT; + let pollAttempts = options?.pollAttempts || API_POLL_ATTEMPTS; + let isFinished = false; + let result; + + while (!isFinished && pollCount < pollAttempts) { + if (this._abortController.signal.aborted) { + Glean?.shoppingProduct?.requestAborted.record(); + return null; + } + let backOff = pollCount == 0 ? initialWait : pollTimeout; + if (backOff) { + await new Promise(resolve => lazy.setTimeout(resolve, backOff)); + } + try { + result = await this.requestAnalysisCreationStatus(undefined, options); + if (result?.progress) { + this.emit("analysis-progress", result.progress); + } + isFinished = + result && + result.status != "pending" && + result.status != "in_progress"; + } catch (error) { + console.error(error); + return null; + } + pollCount++; + } + return result; + } + + /** + * Request that the API creates an analysis for a product. + * + * Once the processing status indicates that analyzing is complete, + * the new analysis data that can be requested with `requestAnalysis`. + * + * If the product is currently being analyzed, this will return a + * status of "in_progress" and not trigger a reanalyzing the product. + * + * @param {Product} product + * Product to request for (defaults to the instances product). + * @param {object} options + * Override default API url and schema. + * @returns {object} result + * Parsed JSON API result or null. + */ + async requestCreateAnalysis(product = this.product, options = {}) { + let url = options?.url || ShoppingEnvironment.ANALYZE_API; + let requestSchema = options?.requestSchema || ANALYZE_REQUEST_SCHEMA; + let responseSchema = options?.responseSchema || ANALYZE_RESPONSE_SCHEMA; + let signal = options?.signal || this._abortController.signal; + let allowValidationFailure = this.allowValidationFailure; + + if (!product) { + return null; + } + + let requestOptions = { + product_id: product.id, + website: product.host, + }; + + let result = await ShoppingProduct.request(url, requestOptions, { + requestSchema, + responseSchema, + signal, + allowValidationFailure, + }); + + return result; + } + + /** + * Check the status of creating an analysis for a product. + * + * API returns a progress of 0-100 complete and the processing status. + * + * @param {Product} product + * Product to request for (defaults to the instances product). + * @param {object} options + * Override default API url and schema. + * @returns {object} result + * Parsed JSON API result or null. + */ + async requestAnalysisCreationStatus(product = this.product, options = {}) { + let url = options?.url || ShoppingEnvironment.ANALYSIS_STATUS_API; + let requestSchema = + options?.requestSchema || ANALYSIS_STATUS_REQUEST_SCHEMA; + let responseSchema = + options?.responseSchema || ANALYSIS_STATUS_RESPONSE_SCHEMA; + let signal = options?.signal || this._abortController.signal; + let allowValidationFailure = this.allowValidationFailure; + + if (!product) { + return null; + } + + let requestOptions = { + product_id: product.id, + website: product.host, + }; + + let result = await ShoppingProduct.request(url, requestOptions, { + requestSchema, + responseSchema, + signal, + allowValidationFailure, + }); + + return result; + } + + /** + * Send an event to the Ad Attribution API + * + * @param {string} eventName + * Event name options are: + * - "impression" + * - "click" + * @param {string} aid + * The aid (Ad ID) from the recommendation. + * @param {string} [source] + * Source of the event + * @param {object} [options] + * Override default API url and schema. + * @returns {object} result + * Parsed JSON API result or null. + */ + static async sendAttributionEvent( + eventName, + aid, + source = "firefox_sidebar", + options = {} + ) { + let { + url = ShoppingEnvironment.ATTRIBUTION_API, + requestSchema = ATTRIBUTION_REQUEST_SCHEMA, + responseSchema = ATTRIBUTION_RESPONSE_SCHEMA, + signal = new AbortController().signal, + allowValidationFailure = true, + } = options; + + if (!eventName) { + throw new Error("An event name is required."); + } + if (!aid) { + throw new Error("An Ad ID is required."); + } + + let requestBody = { + event_source: source, + }; + + switch (eventName) { + case "impression": + requestBody.event_name = "trusted_deals_impression"; + requestBody.aidvs = [aid]; + break; + case "click": + requestBody.event_name = "trusted_deals_link_clicked"; + requestBody.aid = aid; + break; + case "placement": + requestBody.event_name = "trusted_deals_placement"; + requestBody.aidvs = [aid]; + break; + default: + throw new Error(`"${eventName}" is not a valid event name`); + } + + let result = await ShoppingProduct.request(url, requestBody, { + requestSchema, + responseSchema, + signal, + allowValidationFailure, + }); + + return result; + } + + /** + * Send a report that a product is back in stock. + * + * @param {Product} product + * Product to request for (defaults to the instances product). + * @param {object} options + * Override default API url and schema. + * @returns {object} result + * Parsed JSON API result or null. + */ + async sendReport(product = this.product, options = {}) { + if (!product) { + return null; + } + + let url = options?.url || ShoppingEnvironment.REPORTING_API; + let requestSchema = options?.requestSchema || REPORTING_REQUEST_SCHEMA; + let responseSchema = options?.responseSchema || REPORTING_RESPONSE_SCHEMA; + let signal = options?.signal || this._abortController.signal; + let allowValidationFailure = this.allowValidationFailure; + + let requestOptions = { + product_id: product.id, + website: product.host, + }; + + let result = await ShoppingProduct.request(url, requestOptions, { + requestSchema, + responseSchema, + signal, + allowValidationFailure, + }); + + return result; + } + + uninit() { + this._abortController.abort(); + this.product = null; + } +} + +/** + * Check if a URL is a valid product. + * + * @param {URL | nsIURI } url + * URL to check. + * @returns {boolean} + */ +export function isProductURL(url) { + if (url instanceof Ci.nsIURI) { + url = URL.fromURI(url); + } + if (!URL.isInstance(url)) { + return false; + } + let productInfo = ShoppingProduct.fromURL(url); + return ShoppingProduct.isProduct(productInfo); +} diff --git a/toolkit/components/shopping/jar.mn b/toolkit/components/shopping/jar.mn new file mode 100644 index 0000000000..d6a9720b10 --- /dev/null +++ b/toolkit/components/shopping/jar.mn @@ -0,0 +1,20 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +toolkit.jar: + content/global/shopping/ProductConfig.mjs (content/ProductConfig.mjs) + content/global/shopping/ProductValidator.sys.mjs (content/ProductValidator.sys.mjs) + content/global/shopping/ShoppingProduct.mjs (content/ShoppingProduct.mjs) + content/global/shopping/analysis_response.schema.json (schemas/analysis_response.schema.json) + content/global/shopping/recommendations_response.schema.json (schemas/recommendations_response.schema.json) + content/global/shopping/analysis_request.schema.json (schemas/analysis_request.schema.json) + content/global/shopping/recommendations_request.schema.json (schemas/recommendations_request.schema.json) + content/global/shopping/attribution_response.schema.json (schemas/attribution_response.schema.json) + content/global/shopping/attribution_request.schema.json (schemas/attribution_request.schema.json) + content/global/shopping/reporting_response.schema.json (schemas/reporting_response.schema.json) + content/global/shopping/reporting_request.schema.json (schemas/reporting_request.schema.json) + content/global/shopping/analysis_status_request.schema.json (schemas/analysis_status_request.schema.json) + content/global/shopping/analysis_status_response.schema.json (schemas/analysis_status_response.schema.json) + content/global/shopping/analyze_request.schema.json (schemas/analyze_request.schema.json) + content/global/shopping/analyze_response.schema.json (schemas/analyze_response.schema.json) diff --git a/toolkit/components/shopping/metrics.yaml b/toolkit/components/shopping/metrics.yaml new file mode 100644 index 0000000000..b357c9ed5f --- /dev/null +++ b/toolkit/components/shopping/metrics.yaml @@ -0,0 +1,160 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - "Firefox :: Shopping" + +shopping_product: + request_error: + type: event + description: | + There was an error requesting the Fakespot API. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_sensitivity: + - technical + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + request_failure: + type: event + description: | + There was a failure with the request to the Fakespot API. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_sensitivity: + - technical + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + server_failure: + type: event + description: | + There was a Fakespot API server issue that prevented + the request from succeeding. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_sensitivity: + - technical + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + request_retried: + type: event + description: | + Status returned a 500 error when requesting the Fakespot API + and will be retried. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_sensitivity: + - technical + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + request_retries_failed: + type: event + description: | + Request still failed after the maxiumn number of + retry events. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_sensitivity: + - technical + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + request_aborted: + type: event + description: | + Request to the Fakespot API was aborted. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_sensitivity: + - technical + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + invalid_request: + type: event + description: | + An invalid JSON request was sent to the Fakespot API. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_sensitivity: + - technical + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + invalid_response: + type: event + description: | + An invalid JSON response was received from the Fakespot API. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_sensitivity: + - technical + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events + invalid_ohttp_config: + type: event + description: | + OHTTP was configured for shopping but the config is invalid. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386 + data_sensitivity: + - technical + expires: 134 + notification_emails: + - betling@mozilla.com + - fx-desktop-shopping-eng@mozilla.com + send_in_pings: + - events diff --git a/toolkit/components/shopping/moz.build b/toolkit/components/shopping/moz.build new file mode 100644 index 0000000000..927854a5e6 --- /dev/null +++ b/toolkit/components/shopping/moz.build @@ -0,0 +1,14 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Shopping") + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] + +JAR_MANIFESTS += ["jar.mn"] diff --git a/toolkit/components/shopping/schemas/analysis_request.schema.json b/toolkit/components/shopping/schemas/analysis_request.schema.json new file mode 100644 index 0000000000..5332f2f842 --- /dev/null +++ b/toolkit/components/shopping/schemas/analysis_request.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/analysis_request.schema.json", + "title": "Product request", + "type": "object", + "properties": { + "product_id": { + "description": "Product identifier (ASIN / SKU).", + "type": "string", + "examples": ["B07W59LRL9", "5200904.p", "1752657021"] + }, + "website": { + "description": "Product websites.", + "type": "string", + "examples": ["amazon.com", "amazon.ca", "bestbuy.com", "walmart.com"] + } + }, + "required": ["product_id", "website"] +} diff --git a/toolkit/components/shopping/schemas/analysis_response.schema.json b/toolkit/components/shopping/schemas/analysis_response.schema.json new file mode 100644 index 0000000000..cb84f9981e --- /dev/null +++ b/toolkit/components/shopping/schemas/analysis_response.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/analysis_response.schema.json", + "title": "Product", + "description": "A product analysis result", + "type": "object", + "properties": { + "product_id": { + "description": "Product identifier (ASIN / SKU).", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "examples": ["B07W59LRL9", "5200904.p", "1752657021"] + }, + "grade": { + "description": "Reliability grade for the product's reviews.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "examples": ["A", "B", "C", "D", "F"] + }, + "adjusted_rating": { + "description": "Product rating adjusted to exclude untrusted reviews.", + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "examples": [4.7, null] + }, + "needs_analysis": { + "description": "Boolean indicating if the analysis is stale.", + "type": "boolean" + }, + "page_not_supported": { + "description": "Boolean indicating if current product page is supported or not.", + "type": "boolean" + }, + "not_enough_reviews": { + "description": "Boolean indicating if the product has enough reviews to analyze.", + "type": "boolean" + }, + "last_analysis_time": { + "description": "Integer indicating last analysis time since 1970-01-01 00:00:00 +0000", + "type": "number" + }, + "deleted_product": { + "description": "Boolean indicating if product is marked as deleted by website in Fakespot database", + "type": "boolean" + }, + "deleted_product_reported": { + "description": "Boolean indicating if product marked as deleted has already been reported as in stock by a user", + "type": "boolean" + }, + "highlights": { + "description": "Object containing highlights for Amazon product.", + "type": "object", + "properties": { + "quality": { + "description": "Highlights related to quality.", + "type": "object", + "$ref": "#/$defs/highlights" + }, + "price": { + "description": "Highlights related to price.", + "type": "object", + "$ref": "#/$defs/highlights" + }, + "shipping": { + "description": "Highlights related to shipping.", + "type": "object", + "$ref": "#/$defs/highlights" + }, + "packaging/appearance": { + "description": "Highlights related to packaging or appearance.", + "type": "object", + "$ref": "#/$defs/highlights" + }, + "competitiveness": { + "description": "Highlights related to competitiveness.", + "type": "object", + "$ref": "#/$defs/highlights" + } + } + } + }, + "$defs": { + "highlights": { + "description": "Possibly empty array of highlights.", + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/toolkit/components/shopping/schemas/analysis_status_request.schema.json b/toolkit/components/shopping/schemas/analysis_status_request.schema.json new file mode 100644 index 0000000000..8015b1bbcb --- /dev/null +++ b/toolkit/components/shopping/schemas/analysis_status_request.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/analysis_status_request.schema.json", + "title": "Analysis Status request", + "type": "object", + "properties": { + "product_id": { + "description": "Product identifier (ASIN / SKU).", + "type": "string", + "examples": ["B07W59LRL9", "5200904.p", "1752657021"] + }, + "website": { + "description": "Product websites.", + "type": "string", + "examples": ["amazon.com", "amazon.ca", "bestbuy.com", "walmart.com"] + } + }, + "required": ["product_id", "website"] +} diff --git a/toolkit/components/shopping/schemas/analysis_status_response.schema.json b/toolkit/components/shopping/schemas/analysis_status_response.schema.json new file mode 100644 index 0000000000..bb116280de --- /dev/null +++ b/toolkit/components/shopping/schemas/analysis_status_response.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/analysis_status_response.schema.json", + "title": "Analysis Status response", + "type": "object", + "properties": { + "status": { + "description": "Current Analysis status", + "type": "string", + "examples": [ + "not_found", + "pending", + "in_progress", + "completed", + "not_analyzable", + "unprocessable", + "page_not_supported", + "not_enough_reviews", + "stale" + ] + }, + "progress": { + "description": "Current Analysis progress 0.0..100.0", + "type": "number", + "examples": [10.0] + } + }, + "required": ["status", "progress"] +} diff --git a/toolkit/components/shopping/schemas/analyze_request.schema.json b/toolkit/components/shopping/schemas/analyze_request.schema.json new file mode 100644 index 0000000000..f2d957b50d --- /dev/null +++ b/toolkit/components/shopping/schemas/analyze_request.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/analyze_request.schema.json", + "title": "Analyze request", + "type": "object", + "properties": { + "product_id": { + "description": "Product identifier (ASIN / SKU).", + "type": "string", + "examples": ["B07W59LRL9", "5200904.p", "1752657021"] + }, + "website": { + "description": "Product websites.", + "type": "string", + "examples": ["amazon.com", "amazon.ca", "bestbuy.com", "walmart.com"] + } + }, + "required": ["product_id", "website"] +} diff --git a/toolkit/components/shopping/schemas/analyze_response.schema.json b/toolkit/components/shopping/schemas/analyze_response.schema.json new file mode 100644 index 0000000000..ad9ea31707 --- /dev/null +++ b/toolkit/components/shopping/schemas/analyze_response.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/analyze_response.schema.json", + "title": "Analyze response", + "type": "object", + "properties": { + "status": { + "description": "Current Analysis status", + "type": "string", + "examples": [ + "pending", + "in_progress", + "completed", + "not_analyzable", + "unprocessable", + "page_not_supported", + "not_enough_reviews" + ] + } + }, + "required": ["status"] +} diff --git a/toolkit/components/shopping/schemas/attribution_request.schema.json b/toolkit/components/shopping/schemas/attribution_request.schema.json new file mode 100644 index 0000000000..1ab3bd146a --- /dev/null +++ b/toolkit/components/shopping/schemas/attribution_request.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/events_request.schema.json", + "title": "Ad events request", + "type": "object", + "properties": { + "event_name": { + "description": "Event name string", + "type": "string", + "examples": [ + "trusted_deals_impression", + "trusted_deals_link_clicked", + "trusted_deals_placement" + ] + }, + "event_source": { + "description": "Where event was dispatched from", + "type": "string", + "examples": ["firefox_sidebar"] + }, + "aidvs": { + "description": "Ad identifiers for impression", + "type": "array", + "items": { + "type": "string" + } + }, + "aid": { + "description": "Ad identifier for clicks", + "type": "string" + }, + "properties": { + "description": "Extra properties", + "type": "object" + } + }, + "required": ["event_name", "event_source"] +} diff --git a/toolkit/components/shopping/schemas/attribution_response.schema.json b/toolkit/components/shopping/schemas/attribution_response.schema.json new file mode 100644 index 0000000000..db3f8aacdd --- /dev/null +++ b/toolkit/components/shopping/schemas/attribution_response.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/events_response.schema.json", + "title": "Ad events response", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } +} diff --git a/toolkit/components/shopping/schemas/recommendations_request.schema.json b/toolkit/components/shopping/schemas/recommendations_request.schema.json new file mode 100644 index 0000000000..f76b32ea40 --- /dev/null +++ b/toolkit/components/shopping/schemas/recommendations_request.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/recommendations_request.schema.json", + "title": "Recommendations request", + "type": "object", + "properties": { + "product_id": { + "description": "Product identifier (ASIN / SKU).", + "type": "string", + "examples": ["B07W59LRL9", "5200904.p", "1752657021"] + }, + "website": { + "description": "Product websites.", + "type": "string", + "examples": ["amazon.com", "amazon.ca", "bestbuy.com", "walmart.com"] + } + }, + "required": ["product_id", "website"] +} diff --git a/toolkit/components/shopping/schemas/recommendations_response.schema.json b/toolkit/components/shopping/schemas/recommendations_response.schema.json new file mode 100644 index 0000000000..da4a69c26d --- /dev/null +++ b/toolkit/components/shopping/schemas/recommendations_response.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/recommendations_response.schema.json", + "title": "Recommendations", + "description": "Recommendations for a product", + "type": "array", + "items": { + "$ref": "#/$defs/recommendation" + }, + "$defs": { + "recommendation": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "image_url": { + "type": "string" + }, + "price": { + "type": "string" + }, + "currency": { + "type": "string", + "examples": ["USD"] + }, + "grade": { + "description": "Reliability grade for the product's reviews.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "examples": ["A", "B", "C", "D", "F"] + }, + "adjusted_rating": { + "type": "number" + }, + "analysis_url": { + "type": "string" + }, + "sponsored": { + "type": "boolean" + }, + "aid": { + "type": "string" + } + } + } + } +} diff --git a/toolkit/components/shopping/schemas/reporting_request.schema.json b/toolkit/components/shopping/schemas/reporting_request.schema.json new file mode 100644 index 0000000000..09e4237df5 --- /dev/null +++ b/toolkit/components/shopping/schemas/reporting_request.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/reporting_request.schema.json", + "title": "Report back in stock request", + "type": "object", + "properties": { + "product_id": { + "description": "Product identifier (ASIN / SKU).", + "type": "string", + "examples": ["B07W59LRL9", "5200904.p", "1752657021"] + }, + "website": { + "description": "Product websites.", + "type": "string", + "examples": ["amazon.com", "amazon.ca", "bestbuy.com", "walmart.com"] + } + }, + "required": ["product_id", "website"] +} diff --git a/toolkit/components/shopping/schemas/reporting_response.schema.json b/toolkit/components/shopping/schemas/reporting_response.schema.json new file mode 100644 index 0000000000..57ebebc66b --- /dev/null +++ b/toolkit/components/shopping/schemas/reporting_response.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chrome://global/content/shopping/reporting_request.schema.json", + "title": "Report back in stock response", + "type": "object", + "properties": { + "message": { + "description": "String indicating current state", + "type": "string", + "examples": ["report created", "already reported", "not deleted"] + } + } +} diff --git a/toolkit/components/shopping/test/browser/browser.toml b/toolkit/components/shopping/test/browser/browser.toml new file mode 100644 index 0000000000..878080690d --- /dev/null +++ b/toolkit/components/shopping/test/browser/browser.toml @@ -0,0 +1,31 @@ +[DEFAULT] +prefs = [ + "browser.shopping.experience2023.enabled=true", + "browser.shopping.experience2023.optedIn=1", + # Disable the fakespot feature callouts to avoid interference. Individual tests + # that need them can re-enable them as needed. + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features=false", + "toolkit.shopping.environment=test", + "browser.shopping.experience2023.autoOpen.enabled=false", + "browser.shopping.experience2023.autoOpen.userEnabled=true", +] +support-files = [ + "head.js", + "../mockapis/server_helper.js", + "../mockapis/analysis_status.sjs", + "../mockapis/analysis.sjs", + "../mockapis/analyze.sjs", + "../mockapis/attribution.sjs", + "../mockapis/recommendations.sjs", + "../mockapis/reporting.sjs", +] + +["browser_shopping_ad_not_available.js"] + +["browser_shopping_ads_test.js"] + +["browser_shopping_integration.js"] + +["browser_shopping_request_telemetry.js"] + +["browser_shopping_sidebar_messages.js"] diff --git a/toolkit/components/shopping/test/browser/browser_shopping_ad_not_available.js b/toolkit/components/shopping/test/browser/browser_shopping_ad_not_available.js new file mode 100644 index 0000000000..c4b9c69277 --- /dev/null +++ b/toolkit/components/shopping/test/browser/browser_shopping_ad_not_available.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async function () { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ["browser.shopping.experience2023.ads.enabled", true], + ["browser.shopping.experience2023.ads.userEnabled", true], + ], + }); +}); + +/** + * Check that we send the right telemetry if no ad is available. + */ +add_task(async function test_no_ad_available_telemetry() { + await BrowserTestUtils.withNewTab(OTHER_PRODUCT_TEST_URL, async browser => { + let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar"); + Assert.ok(sidebar, "Sidebar should exist"); + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + + info("Waiting for sidebar to update."); + await promiseSidebarAdsUpdated(sidebar, OTHER_PRODUCT_TEST_URL); + // Test the lack of ad was recorded by telemetry + await Services.fog.testFlushAllChildren(); + let noAdsAvailableEvents = + Glean.shopping.surfaceNoAdsAvailable.testGetValue(); + Assert.equal( + noAdsAvailableEvents?.length, + 1, + "Should have recorded lack of ads." + ); + let noAdsEvent = noAdsAvailableEvents?.[0]; + Assert.equal(noAdsEvent?.category, "shopping"); + Assert.equal(noAdsEvent?.name, "surface_no_ads_available"); + }); +}); diff --git a/toolkit/components/shopping/test/browser/browser_shopping_ads_test.js b/toolkit/components/shopping/test/browser/browser_shopping_ads_test.js new file mode 100644 index 0000000000..1061cf7fa6 --- /dev/null +++ b/toolkit/components/shopping/test/browser/browser_shopping_ads_test.js @@ -0,0 +1,236 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +function recommendedAdsEventListener(eventName, sidebar) { + return SpecialPowers.spawn( + sidebar.querySelector("browser"), + [eventName], + name => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + let adEl = shoppingContainer.recommendedAdEl; + return ContentTaskUtils.waitForEvent(adEl, name, false, null, true).then( + ev => null + ); + } + ); +} + +function recommendedAdVisible(sidebar) { + return SpecialPowers.spawn(sidebar.querySelector("browser"), [], async () => { + await ContentTaskUtils.waitForCondition(() => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + return ( + shoppingContainer?.recommendedAdEl && + ContentTaskUtils.isVisible(shoppingContainer?.recommendedAdEl) + ); + }); + }); +} + +add_setup(async function () { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ["browser.shopping.experience2023.ads.enabled", true], + ["browser.shopping.experience2023.ads.userEnabled", true], + ], + }); +}); + +add_task(async function test_ad_attribution() { + await BrowserTestUtils.withNewTab(PRODUCT_TEST_URL, async browser => { + // Test that impression event is fired when opening sidebar + let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar"); + Assert.ok(sidebar, "Sidebar should exist"); + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + let shoppingButton = document.getElementById("shopping-sidebar-button"); + ok( + BrowserTestUtils.isVisible(shoppingButton), + "Shopping Button should be visible on a product page" + ); + + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL); + await recommendedAdVisible(sidebar); + + info("Verifying product info for initial product."); + await verifyProductInfo(sidebar, { + productURL: PRODUCT_TEST_URL, + adjustedRating: "4.1", + letterGrade: "B", + }); + + // Test placement was recorded by telemetry + info("Verifying ad placement event."); + await Services.fog.testFlushAllChildren(); + var adsPlacementEvents = Glean.shopping.surfaceAdsPlacement.testGetValue(); + Assert.equal(adsPlacementEvents.length, 1, "should have recorded an event"); + Assert.equal(adsPlacementEvents[0].category, "shopping"); + Assert.equal(adsPlacementEvents[0].name, "surface_ads_placement"); + + let impressionEvent = recommendedAdsEventListener("AdImpression", sidebar); + + info("Waiting for ad impression event."); + await impressionEvent; + Assert.ok(true, "Got ad impression event"); + + // Test the impression was recorded by telemetry + await Services.fog.testFlushAllChildren(); + var adsImpressionEvents = + Glean.shopping.surfaceAdsImpression.testGetValue(); + Assert.equal( + adsImpressionEvents.length, + 1, + "should have recorded an event" + ); + Assert.equal(adsImpressionEvents[0].category, "shopping"); + Assert.equal(adsImpressionEvents[0].name, "surface_ads_impression"); + + // + // Test that impression event is fired after switching to a tab that was + // opened in the background + + let tab = BrowserTestUtils.addTab(gBrowser, PRODUCT_TEST_URL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + let tabSidebar = gBrowser + .getPanel(tab.linkedBrowser) + .querySelector("shopping-sidebar"); + Assert.ok(tabSidebar, "Sidebar should exist"); + + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(tabSidebar, PRODUCT_TEST_URL); + await recommendedAdVisible(tabSidebar); + + // Need to wait the impression timeout to confirm that no impression event + // has been dispatched + // Bug 1859029 should update this to use sinon fake timers instead of using + // setTimeout + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 2000)); + + let hasImpressed = await SpecialPowers.spawn( + tabSidebar.querySelector("browser"), + [], + () => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + let adEl = shoppingContainer.recommendedAdEl; + return adEl.hasImpressed; + } + ); + Assert.ok(!hasImpressed, "We haven't seend the ad yet"); + + impressionEvent = recommendedAdsEventListener("AdImpression", tabSidebar); + await BrowserTestUtils.switchTab(gBrowser, tab); + await recommendedAdVisible(tabSidebar); + + info("Waiting for ad impression event."); + await impressionEvent; + Assert.ok(true, "Got ad impression event"); + + // + // Test that the impression event is fired after opening foreground tab, + // switching away and the event is not fired, then switching back and the + // event does fire + + gBrowser.removeTab(tab); + + tab = BrowserTestUtils.addTab(gBrowser, PRODUCT_TEST_URL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + tabSidebar = gBrowser + .getPanel(tab.linkedBrowser) + .querySelector("shopping-sidebar"); + Assert.ok(tabSidebar, "Sidebar should exist"); + + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(tabSidebar, PRODUCT_TEST_URL); + await recommendedAdVisible(tabSidebar); + + // Switch to new sidebar tab + await BrowserTestUtils.switchTab(gBrowser, tab); + // switch back to original tab + await BrowserTestUtils.switchTab( + gBrowser, + gBrowser.getTabForBrowser(browser) + ); + + // Need to wait the impression timeout to confirm that no impression event + // has been dispatched + // Bug 1859029 should update this to use sinon fake timers instead of using + // setTimeout + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 2000)); + + hasImpressed = await SpecialPowers.spawn( + tabSidebar.querySelector("browser"), + [], + () => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + let adEl = shoppingContainer.recommendedAdEl; + return adEl.hasImpressed; + } + ); + Assert.ok(!hasImpressed, "We haven't seend the ad yet"); + + impressionEvent = recommendedAdsEventListener("AdImpression", tabSidebar); + await BrowserTestUtils.switchTab(gBrowser, tab); + await recommendedAdVisible(tabSidebar); + + info("Waiting for ad impression event."); + await impressionEvent; + Assert.ok(true, "Got ad impression event"); + + gBrowser.removeTab(tab); + + // + // Test ad clicked event + + let adOpenedTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + PRODUCT_TEST_URL, + true + ); + + let clickedEvent = recommendedAdsEventListener("AdClicked", sidebar); + await SpecialPowers.spawn(sidebar.querySelector("browser"), [], () => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + let adEl = shoppingContainer.recommendedAdEl; + adEl.linkEl.click(); + }); + + let adTab = await adOpenedTabPromise; + + info("Waiting for ad clicked event."); + await clickedEvent; + Assert.ok(true, "Got ad clicked event"); + + // Test the click was recorded by telemetry + await Services.fog.testFlushAllChildren(); + var adsClickedEvents = Glean.shopping.surfaceAdsClicked.testGetValue(); + Assert.equal(adsClickedEvents.length, 1, "should have recorded a click"); + Assert.equal(adsClickedEvents[0].category, "shopping"); + Assert.equal(adsClickedEvents[0].name, "surface_ads_clicked"); + + gBrowser.removeTab(adTab); + Services.fog.testResetFOG(); + }); +}); diff --git a/toolkit/components/shopping/test/browser/browser_shopping_integration.js b/toolkit/components/shopping/test/browser/browser_shopping_integration.js new file mode 100644 index 0000000000..d16b021eb3 --- /dev/null +++ b/toolkit/components/shopping/test/browser/browser_shopping_integration.js @@ -0,0 +1,277 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_sidebar_navigation() { + // Disable OHTTP for now to get this landed; we'll re-enable with proper + // mocking in the near future. + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + await BrowserTestUtils.withNewTab(PRODUCT_TEST_URL, async browser => { + let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar"); + Assert.ok(sidebar, "Sidebar should exist"); + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL); + info("Verifying product info for initial product."); + await verifyProductInfo(sidebar, { + productURL: PRODUCT_TEST_URL, + adjustedRating: "4.1", + letterGrade: "B", + }); + + // Navigate the browser from the parent: + let loadedPromise = Promise.all([ + BrowserTestUtils.browserLoaded(browser, false, OTHER_PRODUCT_TEST_URL), + promiseSidebarUpdated(sidebar, OTHER_PRODUCT_TEST_URL), + ]); + BrowserTestUtils.startLoadingURIString(browser, OTHER_PRODUCT_TEST_URL); + info("Loading another product."); + await loadedPromise; + Assert.ok(sidebar, "Sidebar should exist."); + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + info("Verifying another product."); + await verifyProductInfo(sidebar, { + productURL: OTHER_PRODUCT_TEST_URL, + adjustedRating: "1", + letterGrade: "F", + }); + + // Navigate to a non-product URL: + loadedPromise = BrowserTestUtils.browserLoaded( + browser, + false, + "https://example.com/1" + ); + BrowserTestUtils.startLoadingURIString(browser, "https://example.com/1"); + info("Go to a non-product."); + await loadedPromise; + Assert.ok(BrowserTestUtils.isHidden(sidebar)); + + // Navigate using pushState: + loadedPromise = BrowserTestUtils.waitForLocationChange( + gBrowser, + PRODUCT_TEST_URL + ); + info("Navigate to the first product using pushState."); + await SpecialPowers.spawn(browser, [PRODUCT_TEST_URL], urlToUse => { + content.history.pushState({}, null, urlToUse); + }); + info("Waiting to load first product again."); + await loadedPromise; + info("Waiting for the sidebar to have updated."); + await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL); + Assert.ok(sidebar, "Sidebar should exist"); + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + + info("Waiting to verify the first product a second time."); + await verifyProductInfo(sidebar, { + productURL: PRODUCT_TEST_URL, + adjustedRating: "4.1", + letterGrade: "B", + }); + + // Navigate to a product URL with query params: + loadedPromise = BrowserTestUtils.browserLoaded( + browser, + false, + PRODUCT_TEST_URL + "?th=1" + ); + // Navigate to the same product, but with a th=1 added. + BrowserTestUtils.startLoadingURIString(browser, PRODUCT_TEST_URL + "?th=1"); + // When just comparing URLs product info would be cleared out, + // but when comparing the parsed product ids, we do nothing as the product + // has not changed. + info("Verifying product has not changed before load."); + await verifyProductInfo(sidebar, { + productURL: PRODUCT_TEST_URL, + adjustedRating: "4.1", + letterGrade: "B", + }); + // Wait for the page to load, but don't wait for the sidebar to update so + // we can be sure we still have the previous product info. + await loadedPromise; + info("Verifying product has not changed after load."); + await verifyProductInfo(sidebar, { + productURL: PRODUCT_TEST_URL, + adjustedRating: "4.1", + letterGrade: "B", + }); + }); +}); + +add_task(async function test_button_visible_when_opted_out() { + await BrowserTestUtils.withNewTab( + { + url: PRODUCT_TEST_URL, + gBrowser, + }, + async browser => { + let shoppingBrowser = gBrowser.ownerDocument.querySelector( + "browser.shopping-sidebar" + ); + + let shoppingButton = document.getElementById("shopping-sidebar-button"); + + ok( + BrowserTestUtils.isVisible(shoppingButton), + "Shopping Button should be visible on a product page" + ); + + let sidebar = gBrowser + .getPanel(browser) + .querySelector("shopping-sidebar"); + Assert.ok(sidebar, "Sidebar should exist"); + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL); + + await SpecialPowers.spawn(shoppingBrowser, [], async () => { + let shoppingContainer = + content.document.querySelector("shopping-container").wrappedJSObject; + await shoppingContainer.updateComplete; + let shoppingSettings = shoppingContainer.settingsEl; + await shoppingSettings.updateComplete; + + shoppingSettings.shoppingCardEl.detailsEl.open = true; + let optOutButton = shoppingSettings.optOutButtonEl; + optOutButton.click(); + }); + + await BrowserTestUtils.waitForMutationCondition( + shoppingButton, + { attributes: false, attributeFilter: ["shoppingsidebaropen"] }, + () => shoppingButton.getAttribute("shoppingsidebaropen") + ); + + ok( + !Services.prefs.getBoolPref("browser.shopping.experience2023.active"), + "Shopping sidebar is no longer active" + ); + is( + Services.prefs.getIntPref("browser.shopping.experience2023.optedIn"), + 2, + "Opted out of shopping experience" + ); + + ok( + BrowserTestUtils.isVisible(shoppingButton), + "Shopping Button should be visible after opting out" + ); + + Services.prefs.setBoolPref( + "browser.shopping.experience2023.active", + true + ); + Services.prefs.setIntPref("browser.shopping.experience2023.optedIn", 1); + } + ); +}); + +add_task(async function test_sidebar_button_open_close() { + // Disable OHTTP for now to get this landed; we'll re-enable with proper + // mocking in the near future. + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + await BrowserTestUtils.withNewTab(PRODUCT_TEST_URL, async browser => { + let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar"); + Assert.ok(sidebar, "Sidebar should exist"); + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + let shoppingButton = document.getElementById("shopping-sidebar-button"); + ok( + BrowserTestUtils.isVisible(shoppingButton), + "Shopping Button should be visible on a product page" + ); + + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL); + + info("Verifying product info for initial product."); + await verifyProductInfo(sidebar, { + productURL: PRODUCT_TEST_URL, + adjustedRating: "4.1", + letterGrade: "B", + }); + + // close the sidebar + shoppingButton.click(); + ok(BrowserTestUtils.isHidden(sidebar), "Sidebar should be hidden"); + + // reopen the sidebar + shoppingButton.click(); + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL); + + info("Verifying product info for has not changed."); + await verifyProductInfo(sidebar, { + productURL: PRODUCT_TEST_URL, + adjustedRating: "4.1", + letterGrade: "B", + }); + }); +}); + +add_task(async function test_no_reliability_available() { + Services.fog.testResetFOG(); + await Services.fog.testFlushAllChildren(); + // Disable OHTTP for now to get this landed; we'll re-enable with proper + // mocking in the near future. + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + await BrowserTestUtils.withNewTab(NEEDS_ANALYSIS_TEST_URL, async browser => { + let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar"); + + Assert.ok(sidebar, "Sidebar should exist"); + + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(sidebar, NEEDS_ANALYSIS_TEST_URL); + }); + + await Services.fog.testFlushAllChildren(); + var sawPageEvents = + Glean.shopping.surfaceNoReviewReliabilityAvailable.testGetValue(); + + Assert.equal(sawPageEvents.length, 1); + Assert.equal(sawPageEvents[0].category, "shopping"); + Assert.equal( + sawPageEvents[0].name, + "surface_no_review_reliability_available" + ); +}); diff --git a/toolkit/components/shopping/test/browser/browser_shopping_request_telemetry.js b/toolkit/components/shopping/test/browser/browser_shopping_request_telemetry.js new file mode 100644 index 0000000000..d4b5147e48 --- /dev/null +++ b/toolkit/components/shopping/test/browser/browser_shopping_request_telemetry.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const INVALID_RESPONSE = "https://example.com/Some-Product/dp/INVALID123"; +const SERVICE_UNAVAILABLE = "https://example.com/Some-Product/dp/HTTPERR503"; +const TOO_MANY_REQUESTS = "https://example.com/Some-Product/dp/HTTPERR429"; + +function assertEventMatches(gleanEvents, requiredValues) { + if (!gleanEvents?.length) { + return Assert.ok( + !!gleanEvents?.length, + `${requiredValues?.name} event recorded` + ); + } + let limitedEvent = Object.assign({}, gleanEvents[0]); + for (let k of Object.keys(limitedEvent)) { + if (!requiredValues.hasOwnProperty(k)) { + delete limitedEvent[k]; + } + } + return Assert.deepEqual(limitedEvent, requiredValues); +} + +async function testProductURL(url) { + await BrowserTestUtils.withNewTab( + { + url, + gBrowser, + }, + async browser => { + let sidebar = gBrowser + .getPanel(browser) + .querySelector("shopping-sidebar"); + + await promiseSidebarUpdated(sidebar, url); + } + ); +} + +add_task(async function test_shopping_server_failure_telemetry() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + Services.fog.testResetFOG(); + + await testProductURL(SERVICE_UNAVAILABLE); + + await Services.fog.testFlushAllChildren(); + + const events = Glean.shoppingProduct.serverFailure.testGetValue(); + assertEventMatches(events, { + category: "shopping_product", + name: "server_failure", + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_shopping_request_failure_telemetry() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + Services.fog.testResetFOG(); + + await testProductURL(TOO_MANY_REQUESTS); + + await Services.fog.testFlushAllChildren(); + + const events = Glean.shoppingProduct.requestFailure.testGetValue(); + assertEventMatches(events, { + category: "shopping_product", + name: "request_failure", + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_shopping_request_retried_telemetry() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + Services.fog.testResetFOG(); + + await testProductURL(SERVICE_UNAVAILABLE); + + await Services.fog.testFlushAllChildren(); + + const events = Glean.shoppingProduct.requestRetried.testGetValue(); + assertEventMatches(events, { + category: "shopping_product", + name: "request_retried", + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_shopping_response_invalid_telemetry() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + Services.fog.testResetFOG(); + + await testProductURL(INVALID_RESPONSE); + + await Services.fog.testFlushAllChildren(); + const events = Glean.shoppingProduct.invalidResponse.testGetValue(); + assertEventMatches(events, { + category: "shopping_product", + name: "invalid_response", + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_shopping_ohttp_invalid_telemetry() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", "https://example.com/api"], + ["toolkit.shopping.ohttpConfigURL", "https://example.com/config"], + ], + }); + Services.fog.testResetFOG(); + + await testProductURL(PRODUCT_TEST_URL); + + await Services.fog.testFlushAllChildren(); + + const events = Glean.shoppingProduct.invalidOhttpConfig.testGetValue(); + assertEventMatches(events, { + category: "shopping_product", + name: "invalid_ohttp_config", + }); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/shopping/test/browser/browser_shopping_sidebar_messages.js b/toolkit/components/shopping/test/browser/browser_shopping_sidebar_messages.js new file mode 100644 index 0000000000..969b49481d --- /dev/null +++ b/toolkit/components/shopping/test/browser/browser_shopping_sidebar_messages.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const NOT_ENOUGH_REVIEWS_TEST_URL = + "https://example.com/Bad-Product/dp/N0T3NOUGHR"; +const NOT_SUPPORTED_TEST_URL = "https://example.com/Bad-Product/dp/PAG3N0TSUP"; +const UNPROCESSABLE_TEST_URL = "https://example.com/Bad-Product/dp/UNPR0C3SSA"; + +add_task(async function test_sidebar_error() { + // Disable OHTTP for now to get this landed; we'll re-enable with proper + // mocking in the near future. + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + await BrowserTestUtils.withNewTab(BAD_PRODUCT_TEST_URL, async browser => { + let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar"); + + Assert.ok(sidebar, "Sidebar should exist"); + + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(sidebar, BAD_PRODUCT_TEST_URL); + + info("Verifying a generic error is shown."); + await SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async prodInfo => { + let doc = content.document; + let shoppingContainer = + doc.querySelector("shopping-container").wrappedJSObject; + + ok( + shoppingContainer.shoppingMessageBarEl, + "Got shopping-message-bar element" + ); + is( + shoppingContainer.shoppingMessageBarEl.getAttribute("type"), + "generic-error", + "generic-error type should be correct" + ); + } + ); + }); +}); + +add_task(async function test_sidebar_analysis_status_page_not_supported() { + // Disable OHTTP for now to get this landed; we'll re-enable with proper + // mocking in the near future. + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + + // Product not supported status + await BrowserTestUtils.withNewTab(NOT_SUPPORTED_TEST_URL, async browser => { + let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar"); + + Assert.ok(sidebar, "Sidebar should exist"); + + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(sidebar, NOT_SUPPORTED_TEST_URL); + + info("Verifying a generic error is shown."); + await SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async prodInfo => { + let doc = content.document; + let shoppingContainer = + doc.querySelector("shopping-container").wrappedJSObject; + + ok( + shoppingContainer.shoppingMessageBarEl, + "Got shopping-message-bar element" + ); + is( + shoppingContainer.shoppingMessageBarEl.getAttribute("type"), + "page-not-supported", + "message type should be correct" + ); + } + ); + }); +}); + +add_task(async function test_sidebar_analysis_status_unprocessable() { + // Disable OHTTP for now to get this landed; we'll re-enable with proper + // mocking in the near future. + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + + // Unprocessable status + await BrowserTestUtils.withNewTab(UNPROCESSABLE_TEST_URL, async browser => { + let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar"); + + Assert.ok(sidebar, "Sidebar should exist"); + + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(sidebar, UNPROCESSABLE_TEST_URL); + + info("Verifying a generic error is shown."); + await SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async prodInfo => { + let doc = content.document; + let shoppingContainer = + doc.querySelector("shopping-container").wrappedJSObject; + + ok( + shoppingContainer.shoppingMessageBarEl, + "Got shopping-message-bar element" + ); + is( + shoppingContainer.shoppingMessageBarEl.getAttribute("type"), + "generic-error", + "message type should be correct" + ); + } + ); + }); +}); + +add_task(async function test_sidebar_analysis_status_not_enough_reviews() { + // Disable OHTTP for now to get this landed; we'll re-enable with proper + // mocking in the near future. + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.shopping.ohttpRelayURL", ""], + ["toolkit.shopping.ohttpConfigURL", ""], + ], + }); + // Not enough reviews status + await BrowserTestUtils.withNewTab( + NOT_ENOUGH_REVIEWS_TEST_URL, + async browser => { + let sidebar = gBrowser + .getPanel(browser) + .querySelector("shopping-sidebar"); + + Assert.ok(sidebar, "Sidebar should exist"); + + Assert.ok( + BrowserTestUtils.isVisible(sidebar), + "Sidebar should be visible." + ); + info("Waiting for sidebar to update."); + await promiseSidebarUpdated(sidebar, NOT_ENOUGH_REVIEWS_TEST_URL); + + info("Verifying a generic error is shown."); + await SpecialPowers.spawn( + sidebar.querySelector("browser"), + [], + async prodInfo => { + let doc = content.document; + let shoppingContainer = + doc.querySelector("shopping-container").wrappedJSObject; + + ok( + shoppingContainer.shoppingMessageBarEl, + "Got shopping-message-bar element" + ); + is( + shoppingContainer.shoppingMessageBarEl.getAttribute("type"), + "not-enough-reviews", + "message type should be correct" + ); + } + ); + } + ); +}); diff --git a/toolkit/components/shopping/test/browser/head.js b/toolkit/components/shopping/test/browser/head.js new file mode 100644 index 0000000000..af676bbc33 --- /dev/null +++ b/toolkit/components/shopping/test/browser/head.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PRODUCT_TEST_URL = "https://example.com/Some-Product/dp/ABCDEFG123"; +const OTHER_PRODUCT_TEST_URL = + "https://example.com/Another-Product/dp/HIJKLMN456"; +const BAD_PRODUCT_TEST_URL = "https://example.com/Bad-Product/dp/0000000000"; +const NEEDS_ANALYSIS_TEST_URL = "https://example.com/Bad-Product/dp/OPQRSTU789"; + +async function promiseSidebarUpdated(sidebar, expectedProduct) { + let browser = sidebar.querySelector("browser"); + if ( + !browser.currentURI?.equals(Services.io.newURI("about:shoppingsidebar")) + ) { + await BrowserTestUtils.browserLoaded( + browser, + false, + "about:shoppingsidebar" + ); + } + return SpecialPowers.spawn(browser, [expectedProduct], prod => { + function isProductCurrent() { + let actor = content.windowGlobalChild.getExistingActor("ShoppingSidebar"); + return actor?.getProductURI()?.spec == prod; + } + if ( + isProductCurrent() && + !!content.document.querySelector("shopping-container").wrappedJSObject + .data + ) { + info("Product already loaded."); + return true; + } + info( + "Waiting for product to be updated. Document: " + + content.document.location.href + ); + return ContentTaskUtils.waitForEvent( + content.document, + "Update", + true, + e => { + info("Sidebar updated for product: " + JSON.stringify(e.detail)); + return !!e.detail.data && isProductCurrent(); + }, + true + ).then(e => true); + }); +} + +async function promiseSidebarAdsUpdated(sidebar, expectedProduct) { + await promiseSidebarUpdated(sidebar, expectedProduct); + let browser = sidebar.querySelector("browser"); + return SpecialPowers.spawn(browser, [], () => { + let container = + content.document.querySelector("shopping-container").wrappedJSObject; + if (container.recommendationData) { + return true; + } + return ContentTaskUtils.waitForEvent( + content.document, + "UpdateRecommendations", + true, + null, + true + ).then(e => true); + }); +} + +async function verifyProductInfo(sidebar, expectedProductInfo) { + await SpecialPowers.spawn( + sidebar.querySelector("browser"), + [expectedProductInfo], + async prodInfo => { + let doc = content.document; + let container = doc.querySelector("shopping-container"); + let root = container.shadowRoot; + let reviewReliability = root.querySelector("review-reliability"); + // The async fetch could take some time. + while (!reviewReliability) { + info("Waiting for update."); + await container.updateComplete; + } + let adjustedRating = root.querySelector("adjusted-rating"); + Assert.equal( + reviewReliability.getAttribute("letter"), + prodInfo.letterGrade, + `Should have correct letter grade for product ${prodInfo.id}.` + ); + Assert.equal( + adjustedRating.getAttribute("rating"), + prodInfo.adjustedRating, + `Should have correct adjusted rating for product ${prodInfo.id}.` + ); + Assert.equal( + content.windowGlobalChild + .getExistingActor("ShoppingSidebar") + ?.getProductURI()?.spec, + prodInfo.productURL, + `Should have correct url in the child.` + ); + } + ); +} diff --git a/toolkit/components/shopping/test/mockapis/analysis.sjs b/toolkit/components/shopping/test/mockapis/analysis.sjs new file mode 100644 index 0000000000..2cc154803e --- /dev/null +++ b/toolkit/components/shopping/test/mockapis/analysis.sjs @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function loadHelperScript(path) { + let scriptFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + scriptFile.initWithPath(getState("__LOCATION__")); + scriptFile = scriptFile.parent; + scriptFile.append(path); + let scriptSpec = Services.io.newFileURI(scriptFile).spec; + Services.scriptloader.loadSubScript(scriptSpec, this); +} +/* import-globals-from ./server_helper.js */ +loadHelperScript("server_helper.js"); + +let gResponses = new Map( + Object.entries({ + ABCDEFG123: { needs_analysis: false, grade: "B", adjusted_rating: 4.1 }, + HIJKLMN456: { needs_analysis: false, grade: "F", adjusted_rating: 1.0 }, + OPQRSTU789: { needs_analysis: true }, + INVALID123: { needs_analysis: false, grade: 0.85, adjusted_rating: 1.0 }, + HTTPERR503: { status: 503, error: "Service Unavailable" }, + HTTPERR429: { status: 429, error: "Too Many Requests" }, + }) +); + +function handleRequest(request, response) { + var body = getPostBody(request.bodyInputStream); + let requestData = JSON.parse(body); + let productDetails = gResponses.get(requestData.product_id); + if (!productDetails) { + response.setStatusLine(request.httpVersion, 400, "Bad Request"); + productDetails = { + status: 400, + error: "Bad Request", + }; + } + if (productDetails?.status) { + response.setStatusLine( + request.httpVersion, + productDetails.status, + productDetails.error + ); + } + response.write(JSON.stringify(productDetails)); +} diff --git a/toolkit/components/shopping/test/mockapis/analysis_status.sjs b/toolkit/components/shopping/test/mockapis/analysis_status.sjs new file mode 100644 index 0000000000..4d943a9476 --- /dev/null +++ b/toolkit/components/shopping/test/mockapis/analysis_status.sjs @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function loadHelperScript(path) { + let scriptFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + scriptFile.initWithPath(getState("__LOCATION__")); + scriptFile = scriptFile.parent; + scriptFile.append(path); + let scriptSpec = Services.io.newFileURI(scriptFile).spec; + Services.scriptloader.loadSubScript(scriptSpec, this); +} +/* import-globals-from ./server_helper.js */ +loadHelperScript("server_helper.js"); + +let gResponses = new Map( + Object.entries({ + N0T3NOUGHR: { status: "not_enough_reviews", progress: 100.0 }, + PAG3N0TSUP: { status: "page_not_supported", progress: 100.0 }, + UNPR0C3SSA: { status: "unprocessable", progress: 100.0 }, + }) +); + +function handleRequest(request, response) { + let body = getPostBody(request.bodyInputStream); + let requestData = JSON.parse(body); + let responseData = gResponses.get(requestData.product_id); + if (!responseData) { + // We want the status to be completed for most tests. + responseData = { + status: "completed", + progress: 100.0, + }; + } + response.write(JSON.stringify(responseData)); +} diff --git a/toolkit/components/shopping/test/mockapis/analyze.sjs b/toolkit/components/shopping/test/mockapis/analyze.sjs new file mode 100644 index 0000000000..6f9a8cbcee --- /dev/null +++ b/toolkit/components/shopping/test/mockapis/analyze.sjs @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function loadHelperScript(path) { + let scriptFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + scriptFile.initWithPath(getState("__LOCATION__")); + scriptFile = scriptFile.parent; + scriptFile.append(path); + let scriptSpec = Services.io.newFileURI(scriptFile).spec; + Services.scriptloader.loadSubScript(scriptSpec, this); +} +/* import-globals-from ./server_helper.js */ +loadHelperScript("server_helper.js"); + +function handleRequest(_request, response) { + // We always want the status to be pending for the current tests. + let status = { + status: "pending", + }; + response.write(JSON.stringify(status)); +} diff --git a/toolkit/components/shopping/test/mockapis/attribution.sjs b/toolkit/components/shopping/test/mockapis/attribution.sjs new file mode 100644 index 0000000000..b25cf78b3b --- /dev/null +++ b/toolkit/components/shopping/test/mockapis/attribution.sjs @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function loadHelperScript(path) { + let scriptFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + scriptFile.initWithPath(getState("__LOCATION__")); + scriptFile = scriptFile.parent; + scriptFile.append(path); + let scriptSpec = Services.io.newFileURI(scriptFile).spec; + Services.scriptloader.loadSubScript(scriptSpec, this); +} +/* import-globals-from ./server_helper.js */ +loadHelperScript("server_helper.js"); + +function getPostBody(stream) { + let binaryStream = new BinaryInputStream(stream); + let count = binaryStream.available(); + let arrayBuffer = new ArrayBuffer(count); + while (count > 0) { + let actuallyRead = binaryStream.readArrayBuffer(count, arrayBuffer); + if (!actuallyRead) { + throw new Error("Nothing read from input stream!"); + } + count -= actuallyRead; + } + return new TextDecoder().decode(arrayBuffer); +} + +function handleRequest(request, response) { + var body = getPostBody(request.bodyInputStream); + let requestData = JSON.parse(body); + + let key = requestData.aid || (requestData.aidvs && requestData.aidvs[0]); + let responseObj = { + [key]: null, + }; + + response.write(JSON.stringify(responseObj)); +} diff --git a/toolkit/components/shopping/test/mockapis/recommendations.sjs b/toolkit/components/shopping/test/mockapis/recommendations.sjs new file mode 100644 index 0000000000..2c4abc23d2 --- /dev/null +++ b/toolkit/components/shopping/test/mockapis/recommendations.sjs @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function loadHelperScript(path) { + let scriptFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + scriptFile.initWithPath(getState("__LOCATION__")); + scriptFile = scriptFile.parent; + scriptFile.append(path); + let scriptSpec = Services.io.newFileURI(scriptFile).spec; + Services.scriptloader.loadSubScript(scriptSpec, this); +} +/* import-globals-from ./server_helper.js */ +loadHelperScript("server_helper.js"); + +let gResponses = new Map( + Object.entries({ + ABCDEFG123: [ + { + name: "VIVO Electric 60 x 24 inch Stand Up Desk | Black Table Top, Black Frame, Height Adjustable Standing Workstation with Memory Preset Controller (DESK-KIT-1B6B)", + url: "https://example.com/Some-Product/dp/ABCDEFG123", + image_url: "https://example.com/api/image.jpg", + price: "249.99", + currency: "USD", + grade: "A", + adjusted_rating: 4.6, + analysis_url: + "https://www.fakespot.com/product/vivo-electric-60-x-24-inch-stand-up-desk-black-table-top-black-frame-height-adjustable-standing-workstation-with-memory-preset-controller-desk-kit-1b6b", + sponsored: true, + aid: "ELcC6OziGKu2jjQsCf5xMYHTpyPKFtjl/4mKPeygbSuQMyIqF/gkY3bTTznoMmNv0OsPV5uql0/NdzNsoguccIS0BujM3DwBADvkGIKLLF2WX0u3G+B2tvRpZmbTmC1NQW0ivS/KX7dTrRjae3Z84fs0i0PySM68buCo5JY848wvzdlyTfCrcT0B3/Ov0tJjZdy9FupF9skLuFtx0/lgElSRsnoGow/H--uLo2Tq7E++RNxgsl--YqlLhv5n8iGFYQwBD61VBg==", + }, + ], + HIJKLMN456: [], + OPQRSTU789: [], + }) +); + +function handleRequest(request, response) { + var body = getPostBody(request.bodyInputStream); + let requestData = JSON.parse(body); + let recommendation = gResponses.get(requestData.product_id); + + response.write(JSON.stringify(recommendation)); +} diff --git a/toolkit/components/shopping/test/mockapis/reporting.sjs b/toolkit/components/shopping/test/mockapis/reporting.sjs new file mode 100644 index 0000000000..1f4a4115e5 --- /dev/null +++ b/toolkit/components/shopping/test/mockapis/reporting.sjs @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const BinaryInputStream = Components.Constructor( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +function getPostBody(stream) { + let binaryStream = new BinaryInputStream(stream); + let count = binaryStream.available(); + let arrayBuffer = new ArrayBuffer(count); + while (count > 0) { + let actuallyRead = binaryStream.readArrayBuffer(count, arrayBuffer); + if (!actuallyRead) { + throw new Error("Nothing read from input stream!"); + } + count -= actuallyRead; + } + return new TextDecoder().decode(arrayBuffer); +} + +let gResponses = new Map( + Object.entries({ + ABCDEFG123: { message: "report created" }, + HIJKLMN456: { message: "already reported" }, + OPQRSTU789: { message: "not deleted" }, + }) +); + +function handleRequest(request, response) { + var body = getPostBody(request.bodyInputStream); + let requestData = JSON.parse(body); + let report = gResponses.get(requestData.product_id); + + response.write(JSON.stringify(report)); +} diff --git a/toolkit/components/shopping/test/mockapis/server_helper.js b/toolkit/components/shopping/test/mockapis/server_helper.js new file mode 100644 index 0000000000..e5d3a1d591 --- /dev/null +++ b/toolkit/components/shopping/test/mockapis/server_helper.js @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +/* exported getPostBody */ + +const BinaryInputStream = Components.Constructor( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +// eslint-disable-next-line mozilla/reject-importGlobalProperties +Cu.importGlobalProperties(["TextDecoder"]); + +function getPostBody(stream) { + let binaryStream = new BinaryInputStream(stream); + let count = binaryStream.available(); + let arrayBuffer = new ArrayBuffer(count); + while (count > 0) { + let actuallyRead = binaryStream.readArrayBuffer(count, arrayBuffer); + if (!actuallyRead) { + throw new Error("Nothing read from input stream!"); + } + count -= actuallyRead; + } + return new TextDecoder().decode(arrayBuffer); +} diff --git a/toolkit/components/shopping/test/xpcshell/data/analysis_request.json b/toolkit/components/shopping/test/xpcshell/data/analysis_request.json new file mode 100644 index 0000000000..28efa08fdf --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/analysis_request.json @@ -0,0 +1,4 @@ +{ + "product_id": "B07W59LRL9", + "website": "amazon.com" +} diff --git a/toolkit/components/shopping/test/xpcshell/data/analysis_response.json b/toolkit/components/shopping/test/xpcshell/data/analysis_response.json new file mode 100644 index 0000000000..d3413b0643 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/analysis_response.json @@ -0,0 +1,24 @@ +{ + "product_id": "B07W59LRL9", + "grade": "B", + "adjusted_rating": 4.7, + "needs_analysis": false, + "analysis_url": "https://staging.fakespot.com/product/garmin-010-02157-10-fenix-6x-sapphire-premium-multisport-gps-watch-features-mapping-music-grade-adjusted-pace-guidance-and-pulse-ox-sensors-dark-gray-with-black-band", + "highlights": { + "price": ["This watch is great and the price was even better."], + "quality": [ + "Other than that, I am very impressed with the watch and it’s capabilities.", + "This watch performs above expectations in every way with the exception of the heart rate monitor.", + "Battery life is no better than the 3 even with the solar gimmick, probably worse.", + "I have small wrists and still went with the 6X and glad I did.", + "I can deal with the looks, as Im now retired." + ], + "competitiveness": [ + "Bought this to replace my vivoactive 3.", + "I like that this watch has so many features, especially those that monitor health like SP02, respiration, sleep, HRV status, stress, and heart rate.", + "I do not use it for sleep or heartrate monitoring so not sure how accurate they are.", + "I've avoided getting a smartwatch for so long due to short battery life on most of them." + ], + "packaging/appearance": ["I loved the minimalist cardboard packaging"] + } +} diff --git a/toolkit/components/shopping/test/xpcshell/data/analysis_status_completed_response.json b/toolkit/components/shopping/test/xpcshell/data/analysis_status_completed_response.json new file mode 100644 index 0000000000..4c17e006ba --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/analysis_status_completed_response.json @@ -0,0 +1,4 @@ +{ + "status": "completed", + "progress": 100.0 +} diff --git a/toolkit/components/shopping/test/xpcshell/data/analysis_status_in_progress_response.json b/toolkit/components/shopping/test/xpcshell/data/analysis_status_in_progress_response.json new file mode 100644 index 0000000000..6f42fa495c --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/analysis_status_in_progress_response.json @@ -0,0 +1,4 @@ +{ + "status": "in_progress", + "progress": 50.0 +} diff --git a/toolkit/components/shopping/test/xpcshell/data/analysis_status_pending_response.json b/toolkit/components/shopping/test/xpcshell/data/analysis_status_pending_response.json new file mode 100644 index 0000000000..aa9233cff5 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/analysis_status_pending_response.json @@ -0,0 +1,4 @@ +{ + "status": "pending", + "progress": 0.0 +} diff --git a/toolkit/components/shopping/test/xpcshell/data/analyze_pending.json b/toolkit/components/shopping/test/xpcshell/data/analyze_pending.json new file mode 100644 index 0000000000..500a1fe0d4 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/analyze_pending.json @@ -0,0 +1,3 @@ +{ + "status": "pending" +} diff --git a/toolkit/components/shopping/test/xpcshell/data/attribution_response.json b/toolkit/components/shopping/test/xpcshell/data/attribution_response.json new file mode 100644 index 0000000000..4d11d41ca4 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/attribution_response.json @@ -0,0 +1,3 @@ +{ + "1ALhiNLkZ2yR4al5lcP1Npbtlpl5toDfKRgJOATjeieAL6i5Dul99l9+ZTiIWyybUzGysChAdrOA6BWrMqr0EvjoymiH3veZ++XuOvJnC0y1NB/IQQtUzlYEO028XqVUJWJeJte47nPhnK2pSm2QhbdeKbxEnauKAty1cFQeEaBUP7LkvUgxh1GDzflwcVfuKcgMr7hOM3NzjYR2RN3vhmT385Ps4wUj--cv2ucc+1nozldFrl--i9GYyjuHYFFi+EgXXZ3ZsA==": null +} diff --git a/toolkit/components/shopping/test/xpcshell/data/bad_request.json b/toolkit/components/shopping/test/xpcshell/data/bad_request.json new file mode 100644 index 0000000000..b6a9dff9e0 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/bad_request.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "error": "Bad Request" +} diff --git a/toolkit/components/shopping/test/xpcshell/data/image.jpg b/toolkit/components/shopping/test/xpcshell/data/image.jpg Binary files differnew file mode 100644 index 0000000000..78e77baed6 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/image.jpg diff --git a/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_request.json b/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_request.json new file mode 100644 index 0000000000..008fce718c --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_request.json @@ -0,0 +1,4 @@ +{ + "product_id": 12345, + "website": "amazon.com" +} diff --git a/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_response.json b/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_response.json new file mode 100644 index 0000000000..2210e97487 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_response.json @@ -0,0 +1,7 @@ +{ + "product_id": "B07W59LRL9", + "grade": 0.85, + "adjusted_rating": "4.7", + "needs_analysis": true, + "highlights": {} +} diff --git a/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_request.json b/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_request.json new file mode 100644 index 0000000000..454cf49942 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_request.json @@ -0,0 +1,4 @@ +{ + "product_id": 12345, + "website": "" +} diff --git a/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_response.json b/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_response.json new file mode 100644 index 0000000000..7f3ffb8029 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_response.json @@ -0,0 +1,14 @@ +[ + { + "name": "VIVO Electric 60 x 24 inch Stand Up Desk | Black Table Top, Black Frame, Height Adjustable Standing Workstation with Memory Preset Controller (DESK-KIT-1B6B)", + "url": "http://amazon.com/dp/B07V6ZSHF4", + "image_url": "https://i.fakespot.io/b6vx27xf3rgwr1a597q6qd3rutp6", + "price": 249.99, + "currency": "USD", + "grade": 0.5, + "adjusted_rating": "4.6", + "analysis_url": "https://www.fakespot.com/product/vivo-electric-60-x-24-inch-stand-up-desk-black-table-top-black-frame-height-adjustable-standing-workstation-with-memory-preset-controller-desk-kit-1b6b", + "sponsored": true, + "aid": "ELcC6OziGKu2jjQsCf5xMYHTpyPKFtjl/4mKPeygbSuQMyIqF/gkY3bTTznoMmNv0OsPV5uql0/NdzNsoguccIS0BujM3DwBADvkGIKLLF2WX0u3G+B2tvRpZmbTmC1NQW0ivS/KX7dTrRjae3Z84fs0i0PySM68buCo5JY848wvzdlyTfCrcT0B3/Ov0tJjZdy9FupF9skLuFtx0/lgElSRsnoGow/H--uLo2Tq7E++RNxgsl--YqlLhv5n8iGFYQwBD61VBg==" + } +] diff --git a/toolkit/components/shopping/test/xpcshell/data/needs_analysis_response.json b/toolkit/components/shopping/test/xpcshell/data/needs_analysis_response.json new file mode 100644 index 0000000000..3c1cc93a3f --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/needs_analysis_response.json @@ -0,0 +1,6 @@ +{ + "product_id": null, + "grade": null, + "adjusted_rating": null, + "needs_analysis": true +} diff --git a/toolkit/components/shopping/test/xpcshell/data/recommendations_request.json b/toolkit/components/shopping/test/xpcshell/data/recommendations_request.json new file mode 100644 index 0000000000..fed419f993 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/recommendations_request.json @@ -0,0 +1,4 @@ +{ + "product_id": "B0C2T6SQJC", + "website": "amazon.com" +} diff --git a/toolkit/components/shopping/test/xpcshell/data/recommendations_response.json b/toolkit/components/shopping/test/xpcshell/data/recommendations_response.json new file mode 100644 index 0000000000..89dda89581 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/recommendations_response.json @@ -0,0 +1,14 @@ +[ + { + "name": "VIVO Electric 60 x 24 inch Stand Up Desk | Black Table Top, Black Frame, Height Adjustable Standing Workstation with Memory Preset Controller (DESK-KIT-1B6B)", + "url": "http://amazon.com/dp/B07V6ZSHF4", + "image_url": "http://example.com/api/image.jpg", + "price": "249.99", + "currency": "USD", + "grade": "A", + "adjusted_rating": 4.6, + "analysis_url": "https://www.fakespot.com/product/vivo-electric-60-x-24-inch-stand-up-desk-black-table-top-black-frame-height-adjustable-standing-workstation-with-memory-preset-controller-desk-kit-1b6b", + "sponsored": true, + "aid": "ELcC6OziGKu2jjQsCf5xMYHTpyPKFtjl/4mKPeygbSuQMyIqF/gkY3bTTznoMmNv0OsPV5uql0/NdzNsoguccIS0BujM3DwBADvkGIKLLF2WX0u3G+B2tvRpZmbTmC1NQW0ivS/KX7dTrRjae3Z84fs0i0PySM68buCo5JY848wvzdlyTfCrcT0B3/Ov0tJjZdy9FupF9skLuFtx0/lgElSRsnoGow/H--uLo2Tq7E++RNxgsl--YqlLhv5n8iGFYQwBD61VBg==" + } +] diff --git a/toolkit/components/shopping/test/xpcshell/data/report_response.json b/toolkit/components/shopping/test/xpcshell/data/report_response.json new file mode 100644 index 0000000000..9f049555b1 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/report_response.json @@ -0,0 +1,3 @@ +{ + "message": "report created" +} diff --git a/toolkit/components/shopping/test/xpcshell/data/service_unavailable.json b/toolkit/components/shopping/test/xpcshell/data/service_unavailable.json new file mode 100644 index 0000000000..c863e52262 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/service_unavailable.json @@ -0,0 +1,4 @@ +{ + "status": 503, + "error": "Service Unavailable" +} diff --git a/toolkit/components/shopping/test/xpcshell/data/too_many_requests.json b/toolkit/components/shopping/test/xpcshell/data/too_many_requests.json new file mode 100644 index 0000000000..f2c5e48524 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/too_many_requests.json @@ -0,0 +1,4 @@ +{ + "status": 429, + "error": "Too Many Requests" +} diff --git a/toolkit/components/shopping/test/xpcshell/data/unprocessable_entity.json b/toolkit/components/shopping/test/xpcshell/data/unprocessable_entity.json new file mode 100644 index 0000000000..8f3fdc745a --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/data/unprocessable_entity.json @@ -0,0 +1,4 @@ +{ + "status": 422, + "error": "Unprocessable entity" +} diff --git a/toolkit/components/shopping/test/xpcshell/head.js b/toolkit/components/shopping/test/xpcshell/head.js new file mode 100644 index 0000000000..22ec9a9742 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/head.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +/* exported createHttpServer, loadJSONfromFile, readFile */ + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" +); + +const createHttpServer = (...args) => { + AddonTestUtils.maybeInit(this); + return AddonTestUtils.createHttpServer(...args); +}; + +async function loadJSONfromFile(path) { + let file = do_get_file(path); + let uri = Services.io.newFileURI(file); + return fetch(uri.spec).then(resp => { + if (!resp.ok) { + return undefined; + } + return resp.json(); + }); +} + +function readFile(path) { + let file = do_get_file(path); + let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + fstream.init(file, -1, 0, 0); + let data = NetUtil.readInputStreamToString(fstream, fstream.available()); + fstream.close(); + return data; +} + +/* These are constants but declared `var` so they can be used by the individual + * test files. + */ +var API_OHTTP_RELAY = "http://example.com/relay/"; +var API_OHTTP_CONFIG = "http://example.com/ohttp-config"; + +function enableOHTTP(configURL = API_OHTTP_CONFIG) { + Services.prefs.setCharPref("toolkit.shopping.ohttpConfigURL", configURL); + Services.prefs.setCharPref("toolkit.shopping.ohttpRelayURL", API_OHTTP_RELAY); +} + +function disableOHTTP() { + for (let pref of ["ohttpRelayURL", "ohttpConfigURL"]) { + Services.prefs.setCharPref(`toolkit.shopping.${pref}`, ""); + } +} diff --git a/toolkit/components/shopping/test/xpcshell/test_fetchImage.js b/toolkit/components/shopping/test/xpcshell/test_fetchImage.js new file mode 100644 index 0000000000..c0f6965904 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/test_fetchImage.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { ShoppingProduct } = ChromeUtils.importESModule( + "chrome://global/content/shopping/ShoppingProduct.mjs" +); +const IMAGE_URL = "http://example.com/api/image.jpg"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/api/", do_get_file("/data")); + +function BinaryHttpResponse(status, headerNames, headerValues, content) { + this.status = status; + this.headerNames = headerNames; + this.headerValues = headerValues; + this.content = content; +} + +BinaryHttpResponse.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIBinaryHttpResponse"]), +}; + +let ohttp = Cc["@mozilla.org/network/oblivious-http;1"].getService( + Ci.nsIObliviousHttp +); +let ohttpServer = ohttp.server(); + +server.registerPathHandler( + new URL(API_OHTTP_CONFIG).pathname, + (request, response) => { + let bstream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance( + Ci.nsIBinaryOutputStream + ); + bstream.setOutputStream(response.bodyOutputStream); + bstream.writeByteArray(ohttpServer.encodedConfig); + } +); + +let gExpectedOHTTPMethod = "GET"; +server.registerPathHandler( + new URL(API_OHTTP_RELAY).pathname, + async (request, response) => { + let inputStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + inputStream.setInputStream(request.bodyInputStream); + let requestBody = inputStream.readByteArray(inputStream.available()); + let ohttpRequest = ohttpServer.decapsulate(requestBody); + let bhttp = Cc["@mozilla.org/network/binary-http;1"].getService( + Ci.nsIBinaryHttp + ); + let decodedRequest = bhttp.decodeRequest(ohttpRequest.request); + Assert.equal( + decodedRequest.method, + gExpectedOHTTPMethod, + "Should get expected HTTP method" + ); + Assert.deepEqual(decodedRequest.headerNames.sort(), [ + "Accept", + "Content-Type", + ]); + Assert.deepEqual(decodedRequest.headerValues, ["image/jpeg", "image/jpeg"]); + + response.processAsync(); + let innerResponse = await fetch("http://example.com" + decodedRequest.path); + let bytes = new Uint8Array(await innerResponse.arrayBuffer()); + let binaryResponse = new BinaryHttpResponse( + innerResponse.status, + ["Content-Type"], + ["image/jpeg"], + bytes + ); + let encResponse = ohttpRequest.encapsulate( + bhttp.encodeResponse(binaryResponse) + ); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "message/ohttp-res", false); + + let bstream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance( + Ci.nsIBinaryOutputStream + ); + bstream.setOutputStream(response.bodyOutputStream); + bstream.writeByteArray(encResponse); + response.finish(); + } +); + +add_task(async function test_product_requestImageBlob() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + let img = await ShoppingProduct.requestImageBlob(IMAGE_URL); + + Assert.ok(Blob.isInstance(img), "Image is loaded and returned as a blob"); + + enableOHTTP(); + img = await ShoppingProduct.requestImageBlob(IMAGE_URL); + disableOHTTP(); + + Assert.ok(Blob.isInstance(img), "Image is loaded and returned as a blob"); +}); diff --git a/toolkit/components/shopping/test/xpcshell/test_product.js b/toolkit/components/shopping/test/xpcshell/test_product.js new file mode 100644 index 0000000000..ec11e502f6 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/test_product.js @@ -0,0 +1,940 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; +/* global createHttpServer, readFile */ +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +function BinaryHttpResponse(status, headerNames, headerValues, content) { + this.status = status; + this.headerNames = headerNames; + this.headerValues = headerValues; + this.content = content; +} + +BinaryHttpResponse.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIBinaryHttpResponse"]), +}; + +const { + ANALYSIS_RESPONSE_SCHEMA, + ANALYSIS_REQUEST_SCHEMA, + RECOMMENDATIONS_RESPONSE_SCHEMA, + RECOMMENDATIONS_REQUEST_SCHEMA, + ATTRIBUTION_RESPONSE_SCHEMA, + ATTRIBUTION_REQUEST_SCHEMA, + ANALYZE_RESPONSE_SCHEMA, + ANALYZE_REQUEST_SCHEMA, + ANALYSIS_STATUS_RESPONSE_SCHEMA, + ANALYSIS_STATUS_REQUEST_SCHEMA, +} = ChromeUtils.importESModule( + "chrome://global/content/shopping/ProductConfig.mjs" +); + +const { ShoppingProduct } = ChromeUtils.importESModule( + "chrome://global/content/shopping/ShoppingProduct.mjs" +); + +const ANALYSIS_API_MOCK = "http://example.com/api/analysis_response.json"; +const RECOMMENDATIONS_API_MOCK = + "http://example.com/api/recommendations_response.json"; +const ATTRIBUTION_API_MOCK = "http://example.com/api/attribution_response.json"; +const ANALYSIS_API_MOCK_INVALID = + "http://example.com/api/invalid_analysis_response.json"; +const API_SERVICE_UNAVAILABLE = + "http://example.com/errors/service_unavailable.json"; +const API_ERROR_ONCE = "http://example.com/errors/error_once.json"; +const API_ERROR_BAD_REQUEST = "http://example.com/errors/bad_request.json"; +const API_ERROR_UNPROCESSABLE = + "http://example.com/errors/unprocessable_entity.json"; +const API_ERROR_TOO_MANY_REQUESTS = + "http://example.com/errors/too_many_requests.json"; +const API_POLL = "http://example.com/poll/poll_analysis_response.json"; +const API_ANALYSIS_IN_PROGRESS = + "http://example.com/poll/analysis_in_progress.json"; +const REPORTING_API_MOCK = "http://example.com/api/report_response.json"; +const ANALYZE_API_MOCK = "http://example.com/api/analyze_pending.json"; + +const TEST_AID = + "1ALhiNLkZ2yR4al5lcP1Npbtlpl5toDfKRgJOATjeieAL6i5Dul99l9+ZTiIWyybUzGysChAdrOA6BWrMqr0EvjoymiH3veZ++XuOvJnC0y1NB/IQQtUzlYEO028XqVUJWJeJte47nPhnK2pSm2QhbdeKbxEnauKAty1cFQeEaBUP7LkvUgxh1GDzflwcVfuKcgMr7hOM3NzjYR2RN3vhmT385Ps4wUj--cv2ucc+1nozldFrl--i9GYyjuHYFFi+EgXXZ3ZsA=="; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/api/", do_get_file("/data")); + +// Path to test API call that will always fail. +server.registerPathHandler( + new URL(API_SERVICE_UNAVAILABLE).pathname, + (request, response) => { + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + response.setHeader( + "Content-Type", + "application/json; charset=utf-8", + false + ); + response.write(readFile("data/service_unavailable.json", false)); + } +); + +// Path to test API call that will fail once and then succeeded. +let apiErrors = 0; +server.registerPathHandler( + new URL(API_ERROR_ONCE).pathname, + (request, response) => { + response.setHeader( + "Content-Type", + "application/json; charset=utf-8", + false + ); + if (apiErrors == 0) { + response.setStatusLine(request.httpVersion, 503, "Service Unavailable"); + response.write(readFile("/data/service_unavailable.json")); + apiErrors++; + } else { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(readFile("/data/analysis_response.json")); + apiErrors = 0; + } + } +); + +// Request is missing required parameters. +server.registerPathHandler( + new URL(API_ERROR_BAD_REQUEST).pathname, + (request, response) => { + response.setStatusLine(request.httpVersion, 400, "Bad Request"); + response.setHeader( + "Content-Type", + "application/json; charset=utf-8", + false + ); + response.write(readFile("data/bad_request.json", false)); + } +); + +// Request contains a nonsense product identifier or non supported website. +server.registerPathHandler( + new URL(API_ERROR_UNPROCESSABLE).pathname, + (request, response) => { + response.setStatusLine(request.httpVersion, 422, "Unprocessable entity"); + response.setHeader( + "Content-Type", + "application/json; charset=utf-8", + false + ); + response.write(readFile("data/unprocessable_entity.json", false)); + } +); + +// Too many requests to the API. +server.registerPathHandler( + new URL(API_ERROR_TOO_MANY_REQUESTS).pathname, + (request, response) => { + response.setStatusLine(request.httpVersion, 429, "Too many requests"); + response.setHeader( + "Content-Type", + "application/json; charset=utf-8", + false + ); + response.write(readFile("data/too_many_requests.json", false)); + } +); + +// Path to test API call that will still be processing twice and then succeeded. +let pollingTries = 0; +server.registerPathHandler(new URL(API_POLL).pathname, (request, response) => { + response.setHeader("Content-Type", "application/json; charset=utf-8", false); + if (pollingTries == 0) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(readFile("/data/analysis_status_pending_response.json")); + pollingTries++; + } else if (pollingTries == 1) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(readFile("/data/analysis_status_in_progress_response.json")); + pollingTries++; + } else { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(readFile("/data/analysis_status_completed_response.json")); + pollingTries = 0; + } +}); + +// Path to test API call that will always need analysis. +server.registerPathHandler( + new URL(API_ANALYSIS_IN_PROGRESS).pathname, + (request, response) => { + response.setHeader( + "Content-Type", + "application/json; charset=utf-8", + false + ); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(readFile("/data/analysis_status_in_progress_response.json")); + } +); + +let ohttp = Cc["@mozilla.org/network/oblivious-http;1"].getService( + Ci.nsIObliviousHttp +); +let ohttpServer = ohttp.server(); + +server.registerPathHandler( + new URL(API_OHTTP_CONFIG).pathname, + (request, response) => { + let bstream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance( + Ci.nsIBinaryOutputStream + ); + bstream.setOutputStream(response.bodyOutputStream); + bstream.writeByteArray(ohttpServer.encodedConfig); + } +); + +let gExpectedOHTTPMethod = "POST"; +let gExpectedProductDetails; +server.registerPathHandler( + new URL(API_OHTTP_RELAY).pathname, + async (request, response) => { + let inputStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + inputStream.setInputStream(request.bodyInputStream); + let requestBody = inputStream.readByteArray(inputStream.available()); + let ohttpRequest = ohttpServer.decapsulate(requestBody); + let bhttp = Cc["@mozilla.org/network/binary-http;1"].getService( + Ci.nsIBinaryHttp + ); + let decodedRequest = bhttp.decodeRequest(ohttpRequest.request); + Assert.equal( + decodedRequest.method, + gExpectedOHTTPMethod, + "Should get expected HTTP method" + ); + Assert.deepEqual(decodedRequest.headerNames.sort(), [ + "Accept", + "Content-Type", + ]); + Assert.deepEqual(decodedRequest.headerValues, [ + "application/json", + "application/json", + ]); + if (gExpectedOHTTPMethod == "POST") { + Assert.equal( + new TextDecoder().decode(new Uint8Array(decodedRequest.content)), + gExpectedProductDetails, + "Expected body content." + ); + } + + response.processAsync(); + let innerResponse = await fetch("http://example.com" + decodedRequest.path); + let bytes = new Uint8Array(await innerResponse.arrayBuffer()); + let binaryResponse = new BinaryHttpResponse( + innerResponse.status, + ["Content-Type"], + ["application/json"], + bytes + ); + let encResponse = ohttpRequest.encapsulate( + bhttp.encodeResponse(binaryResponse) + ); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "message/ohttp-res", false); + + let bstream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance( + Ci.nsIBinaryOutputStream + ); + bstream.setOutputStream(response.bodyOutputStream); + bstream.writeByteArray(encResponse); + response.finish(); + } +); + +add_task(async function test_product_requestAnalysis() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + let analysis = await product.requestAnalysis(undefined, { + url: ANALYSIS_API_MOCK, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + }); + + Assert.ok( + typeof analysis == "object", + "Analysis object is loaded from JSON and validated" + ); +}); + +add_task(async function test_product_requestAnalysis_OHTTP() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + gExpectedProductDetails = JSON.stringify({ + product_id: "926485654", + website: "walmart.com", + }); + + enableOHTTP(); + + let analysis = await product.requestAnalysis(undefined, { + url: ANALYSIS_API_MOCK, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + }); + + Assert.deepEqual( + analysis, + await fetch(ANALYSIS_API_MOCK).then(r => r.json()), + "Analysis object is loaded from JSON and validated" + ); + + disableOHTTP(); +}); + +add_task(async function test_product_requestAnalysis_invalid() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + let analysis = await product.requestAnalysis(undefined, { + url: ANALYSIS_API_MOCK_INVALID, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + }); + + Assert.equal(analysis, undefined, "Analysis object is invalidated"); +}); + +add_task(async function test_product_requestAnalysis_invalid_allowed() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: true }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + let analysis = await product.requestAnalysis(undefined, { + url: ANALYSIS_API_MOCK_INVALID, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + }); + + Assert.equal(analysis.grade, 0.85, "Analysis is invalid but allowed"); +}); + +add_task(async function test_product_requestAnalysis_broken_config() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + gExpectedProductDetails = JSON.stringify({ + product_id: "926485654", + website: "walmart.com", + }); + + enableOHTTP("http://example.com/thisdoesntexist"); + + let analysis = await product.requestAnalysis(undefined, { + url: ANALYSIS_API_MOCK, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + }); + + // Because the config is missing, the OHTTP request can't be constructed, + // so we should fail. + Assert.equal(analysis, undefined, "Analysis object is invalidated"); + + disableOHTTP(); +}); + +add_task(async function test_product_requestAnalysis_invalid_ohttp() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + gExpectedProductDetails = JSON.stringify({ + product_id: "926485654", + website: "walmart.com", + }); + + enableOHTTP(); + + let analysis = await product.requestAnalysis(undefined, { + url: ANALYSIS_API_MOCK_INVALID, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + }); + + Assert.equal(analysis, undefined, "Analysis object is invalidated"); + + disableOHTTP(); +}); + +add_task(async function test_product_requestAnalysis_invalid_allowed_ohttp() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: true }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + gExpectedProductDetails = JSON.stringify({ + product_id: "926485654", + website: "walmart.com", + }); + + enableOHTTP(); + + let analysis = await product.requestAnalysis(undefined, { + url: ANALYSIS_API_MOCK_INVALID, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + }); + + Assert.equal(analysis.grade, 0.85, "Analysis is invalid but allowed"); + + disableOHTTP(); +}); + +add_task(async function test_product_requestRecommendations() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + if (product.isProduct()) { + let recommendations = await product.requestRecommendations(undefined, { + url: RECOMMENDATIONS_API_MOCK, + requestSchema: RECOMMENDATIONS_REQUEST_SCHEMA, + responseSchema: RECOMMENDATIONS_RESPONSE_SCHEMA, + }); + Assert.ok( + Array.isArray(recommendations), + "Recommendations array is loaded from JSON and validated" + ); + } +}); + +add_task(async function test_product_requestAnalysis_retry_failure() { + const TEST_TIMEOUT = 100; + const RETRIES = 3; + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + let sandbox = sinon.createSandbox(); + let spy = sandbox.spy(ShoppingProduct, "request"); + let startTime = Cu.now(); + let totalTime = TEST_TIMEOUT * Math.pow(2, RETRIES - 1); + + if (product.isProduct()) { + let analysis = await product.requestAnalysis(undefined, { + url: API_SERVICE_UNAVAILABLE, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + }); + Assert.equal(analysis, null, "Analysis object is null"); + Assert.equal( + spy.callCount, + RETRIES + 1, + `Request was retried ${RETRIES} times after a failure` + ); + Assert.ok( + Cu.now() - startTime >= totalTime, + `Waited for at least ${totalTime}ms` + ); + } + sandbox.restore(); +}); + +add_task(async function test_product_requestAnalysis_retry_success() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + let sandbox = sinon.createSandbox(); + let spy = sandbox.spy(ShoppingProduct, "request"); + // Make sure API error count is reset + apiErrors = 0; + if (product.isProduct()) { + let analysis = await product.requestAnalysis(undefined, { + url: API_ERROR_ONCE, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + }); + Assert.equal(spy.callCount, 2, `Request succeeded after a failure`); + Assert.ok( + typeof analysis == "object", + "Analysis object is loaded from JSON and validated" + ); + } + sandbox.restore(); +}); + +add_task(async function test_product_bad_request() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + if (product.isProduct()) { + let errorResult = await product.requestAnalysis(undefined, { + url: API_ERROR_BAD_REQUEST, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + }); + Assert.ok( + typeof errorResult == "object", + "Error object is loaded from JSON" + ); + Assert.equal(errorResult.status, 400, "Error status is passed"); + Assert.equal(errorResult.error, "Bad Request", "Error message is passed"); + } +}); + +add_task(async function test_product_unprocessable_entity() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + if (product.isProduct()) { + let errorResult = await product.requestAnalysis(undefined, { + url: API_ERROR_UNPROCESSABLE, + requestSchema: ANALYSIS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_RESPONSE_SCHEMA, + }); + Assert.ok( + typeof errorResult == "object", + "Error object is loaded from JSON" + ); + Assert.equal(errorResult.status, 422, "Error status is passed"); + Assert.equal( + errorResult.error, + "Unprocessable entity", + "Error message is passed" + ); + } +}); + +add_task(async function test_ohttp_headers() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + gExpectedProductDetails = JSON.stringify({ + product_id: "926485654", + website: "walmart.com", + }); + + enableOHTTP(); + + let configURL = Services.prefs.getCharPref("toolkit.shopping.ohttpConfigURL"); + let config = await ShoppingProduct.getOHTTPConfig(configURL); + Assert.ok(config, "Should have gotten a config."); + let ohttpDetails = await ShoppingProduct.ohttpRequest( + API_OHTTP_RELAY, + config, + ANALYSIS_API_MOCK, + { + method: "POST", + body: gExpectedProductDetails, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + signal: new AbortController().signal, + } + ); + Assert.equal(ohttpDetails.status, 200, "Request should return 200 OK."); + Assert.ok(ohttpDetails.ok, "Request should succeed."); + let responseHeaders = ohttpDetails.headers; + Assert.deepEqual( + responseHeaders, + { "content-type": "application/json" }, + "Should have expected response headers." + ); + disableOHTTP(); +}); + +add_task(async function test_ohttp_too_many_requests() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + gExpectedProductDetails = JSON.stringify({ + product_id: "926485654", + website: "walmart.com", + }); + + enableOHTTP(); + + let configURL = Services.prefs.getCharPref("toolkit.shopping.ohttpConfigURL"); + let config = await ShoppingProduct.getOHTTPConfig(configURL); + Assert.ok(config, "Should have gotten a config."); + let ohttpDetails = await ShoppingProduct.ohttpRequest( + API_OHTTP_RELAY, + config, + API_ERROR_TOO_MANY_REQUESTS, + { + method: "POST", + body: gExpectedProductDetails, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + signal: new AbortController().signal, + } + ); + Assert.equal(ohttpDetails.status, 429, "Request should return 429."); + Assert.equal(ohttpDetails.ok, false, "Request should not be ok."); + + disableOHTTP(); +}); + +add_task(async function test_product_uninit() { + let product = new ShoppingProduct(); + + Assert.equal( + product._abortController.signal.aborted, + false, + "Abort signal is false" + ); + + product.uninit(); + + Assert.equal( + product._abortController.signal.aborted, + true, + "Abort signal is given after uninit" + ); +}); + +add_task(async function test_product_sendAttributionEvent_impression() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + if (product.isProduct()) { + let event = await ShoppingProduct.sendAttributionEvent( + "impression", + TEST_AID, + "firefox_toolkit_tests", + { + url: ATTRIBUTION_API_MOCK, + requestSchema: ATTRIBUTION_REQUEST_SCHEMA, + responseSchema: ATTRIBUTION_RESPONSE_SCHEMA, + } + ); + Assert.deepEqual( + event, + await fetch(ATTRIBUTION_API_MOCK).then(r => r.json()), + "Events object is loaded from JSON and validated" + ); + } +}); + +add_task(async function test_product_sendAttributionEvent_click() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + if (product.isProduct()) { + let event = await ShoppingProduct.sendAttributionEvent( + "click", + TEST_AID, + "firefox_toolkit_tests", + { + url: ATTRIBUTION_API_MOCK, + requestSchema: ATTRIBUTION_REQUEST_SCHEMA, + responseSchema: ATTRIBUTION_RESPONSE_SCHEMA, + } + ); + Assert.deepEqual( + event, + await fetch(ATTRIBUTION_API_MOCK).then(r => r.json()), + "Events object is loaded from JSON and validated" + ); + } +}); + +add_task(async function test_product_sendAttributionEvent_impression_OHTTP() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + gExpectedProductDetails = JSON.stringify({ + event_source: "firefox_toolkit_tests", + event_name: "trusted_deals_impression", + aidvs: [TEST_AID], + }); + + enableOHTTP(); + + let event = await ShoppingProduct.sendAttributionEvent( + "impression", + TEST_AID, + "firefox_toolkit_tests", + { + url: ATTRIBUTION_API_MOCK, + requestSchema: ATTRIBUTION_REQUEST_SCHEMA, + responseSchema: ATTRIBUTION_RESPONSE_SCHEMA, + } + ); + + Assert.deepEqual( + event, + await fetch(ATTRIBUTION_API_MOCK).then(r => r.json()), + "Events object is loaded from JSON and validated" + ); + + disableOHTTP(); +}); + +add_task(async function test_product_sendAttributionEvent_click_OHTTP() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + gExpectedProductDetails = JSON.stringify({ + event_source: "firefox_toolkit_tests", + event_name: "trusted_deals_link_clicked", + aid: TEST_AID, + }); + + enableOHTTP(); + + let event = await ShoppingProduct.sendAttributionEvent( + "click", + TEST_AID, + "firefox_toolkit_tests", + { + url: ATTRIBUTION_API_MOCK, + requestSchema: ATTRIBUTION_REQUEST_SCHEMA, + responseSchema: ATTRIBUTION_RESPONSE_SCHEMA, + } + ); + + Assert.deepEqual( + event, + await fetch(ATTRIBUTION_API_MOCK).then(r => r.json()), + "Events object is loaded from JSON and validated" + ); + + disableOHTTP(); +}); + +add_task(async function test_product_sendAttributionEvent_placement_OHTTP() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + gExpectedProductDetails = JSON.stringify({ + event_source: "firefox_toolkit_tests", + event_name: "trusted_deals_placement", + aidvs: [TEST_AID], + }); + + enableOHTTP(); + + let event = await ShoppingProduct.sendAttributionEvent( + "placement", + TEST_AID, + "firefox_toolkit_tests", + { + url: ATTRIBUTION_API_MOCK, + requestSchema: ATTRIBUTION_REQUEST_SCHEMA, + responseSchema: ATTRIBUTION_RESPONSE_SCHEMA, + } + ); + + Assert.deepEqual( + event, + await fetch(ATTRIBUTION_API_MOCK).then(r => r.json()), + "Events object is loaded from JSON and validated" + ); + + disableOHTTP(); +}); + +add_task(async function test_product_requestAnalysis_poll() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + let sandbox = sinon.createSandbox(); + let spy = sandbox.spy(ShoppingProduct, "request"); + let startTime = Cu.now(); + const INITIAL_TIMEOUT = 100; + const TIMEOUT = 50; + const TRIES = 10; + let totalTime = INITIAL_TIMEOUT + TIMEOUT; + + pollingTries = 0; + if (!product.isProduct()) { + return; + } + let analysis = await product.pollForAnalysisCompleted({ + url: API_POLL, + requestSchema: ANALYSIS_STATUS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_STATUS_RESPONSE_SCHEMA, + pollInitialWait: INITIAL_TIMEOUT, + pollTimeout: TIMEOUT, + pollAttempts: TRIES, + }); + + Assert.equal(spy.callCount, 3, "Request is done processing"); + Assert.ok( + typeof analysis == "object", + "Analysis object is loaded from JSON and validated" + ); + Assert.equal(analysis.status, "completed", "Analysis is completed"); + Assert.equal(analysis.progress, 100.0, "Progress is 100%"); + Assert.ok( + Cu.now() - startTime >= totalTime, + `Waited for at least ${totalTime}ms` + ); + + sandbox.restore(); +}); + +add_task(async function test_product_requestAnalysis_poll_max() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + let sandbox = sinon.createSandbox(); + let spy = sandbox.spy(ShoppingProduct, "request"); + let startTime = Cu.now(); + + const INITIAL_TIMEOUT = 100; + const TIMEOUT = 50; + const TRIES = 4; + let totalTime = INITIAL_TIMEOUT + TIMEOUT * 3; + + pollingTries = 0; + if (!product.isProduct()) { + return; + } + let analysis = await product.pollForAnalysisCompleted({ + url: API_ANALYSIS_IN_PROGRESS, + requestSchema: ANALYSIS_STATUS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_STATUS_RESPONSE_SCHEMA, + pollInitialWait: INITIAL_TIMEOUT, + pollTimeout: TIMEOUT, + pollAttempts: TRIES, + }); + + Assert.equal(spy.callCount, TRIES, "Request is done processing"); + Assert.ok( + typeof analysis == "object", + "Analysis object is loaded from JSON and validated" + ); + Assert.equal(analysis.status, "in_progress", "Analysis not done"); + Assert.ok( + Cu.now() - startTime >= totalTime, + `Waited for at least ${totalTime}ms` + ); + sandbox.restore(); +}); + +add_task(async function test_product_requestAnalysisCreationStatus() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + if (!product.isProduct()) { + return; + } + let analysis = await product.requestAnalysisCreationStatus(undefined, { + url: API_ANALYSIS_IN_PROGRESS, + requestSchema: ANALYSIS_STATUS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_STATUS_RESPONSE_SCHEMA, + }); + Assert.ok( + typeof analysis == "object", + "Analysis object is loaded from JSON and validated" + ); + Assert.equal(analysis.status, "in_progress", "Analysis is in progress"); + Assert.equal(analysis.progress, 50.0, "Progress is 50%"); +}); + +add_task(async function test_product_requestCreateAnalysis() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + if (!product.isProduct()) { + return; + } + let analysis = await product.requestCreateAnalysis(undefined, { + url: ANALYZE_API_MOCK, + requestSchema: ANALYZE_REQUEST_SCHEMA, + responseSchema: ANALYZE_RESPONSE_SCHEMA, + }); + Assert.ok( + typeof analysis == "object", + "Analyze object is loaded from JSON and validated" + ); + Assert.equal(analysis.status, "pending", "Analysis is pending"); +}); + +add_task(async function test_product_sendReport() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + let report = await product.sendReport(undefined, { + url: REPORTING_API_MOCK, + }); + + Assert.ok( + typeof report == "object", + "Report object is loaded from JSON and validated" + ); + Assert.equal(report.message, "report created", "Report is created."); +}); + +add_task(async function test_product_sendReport_OHTTP() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + Assert.ok(product.isProduct(), "Should recognize a valid product."); + + gExpectedProductDetails = JSON.stringify({ + product_id: "926485654", + website: "walmart.com", + }); + + enableOHTTP(); + + let report = await product.sendReport(undefined, { + url: REPORTING_API_MOCK, + }); + + Assert.ok( + typeof report == "object", + "Report object is loaded from JSON and validated" + ); + Assert.equal(report.message, "report created", "Report is created."); + disableOHTTP(); +}); + +add_task(async function test_product_analysisProgress_event() { + let uri = new URL("https://www.walmart.com/ip/926485654"); + let product = new ShoppingProduct(uri, { allowValidationFailure: false }); + + const INITIAL_TIMEOUT = 0; + const TIMEOUT = 0; + const TRIES = 1; + + if (!product.isProduct()) { + return; + } + + let analysisProgressEventData; + product.on("analysis-progress", (eventName, progress) => { + analysisProgressEventData = progress; + }); + + await product.pollForAnalysisCompleted({ + url: API_ANALYSIS_IN_PROGRESS, + requestSchema: ANALYSIS_STATUS_REQUEST_SCHEMA, + responseSchema: ANALYSIS_STATUS_RESPONSE_SCHEMA, + pollInitialWait: INITIAL_TIMEOUT, + pollTimeout: TIMEOUT, + pollAttempts: TRIES, + }); + + Assert.equal( + analysisProgressEventData, + 50, + "Analysis progress event data is emitted" + ); +}); diff --git a/toolkit/components/shopping/test/xpcshell/test_product_urls.js b/toolkit/components/shopping/test/xpcshell/test_product_urls.js new file mode 100644 index 0000000000..ea3fc6da71 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/test_product_urls.js @@ -0,0 +1,297 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { ShoppingProduct, isProductURL } = ChromeUtils.importESModule( + "chrome://global/content/shopping/ShoppingProduct.mjs" +); + +add_task(function test_product_fromUrl() { + Assert.deepEqual( + ShoppingProduct.fromURL(), + { valid: false }, + "Passing a nothing returns empty result object" + ); + + Assert.deepEqual( + ShoppingProduct.fromURL(12345), + { valid: false }, + "Passing a number returns empty result object" + ); + + Assert.deepEqual( + ShoppingProduct.fromURL("https://www.walmart.com/ip/926485654"), + { valid: false }, + "String urls returns empty result object" + ); + + Assert.deepEqual( + ShoppingProduct.fromURL(new URL("https://www.mozilla.org")), + { host: "mozilla.org", valid: false }, + "Invalid Url returns a full result object" + ); + + Assert.deepEqual( + ShoppingProduct.fromURL(new URL("https://www.walmart.com/ip/926485654")) + .host, + "walmart.com", + "WWW in host is ignored" + ); + + Assert.deepEqual( + ShoppingProduct.fromURL( + new URL("https://staging.walmart.com/ip/926485654") + ), + { host: "staging.walmart.com", valid: false }, + "Subdomain in valid Product Url returns partial result" + ); + + Assert.deepEqual( + ShoppingProduct.fromURL(new URL("https://walmart.co.uk/ip/926485654")), + { host: "walmart.co.uk", sitename: "walmart", valid: false }, + "Invalid in Product TLD returns partial result" + ); + + Assert.deepEqual( + ShoppingProduct.fromURL(new URL("https://walmart.com")), + { host: "walmart.com", sitename: "walmart", tld: "com", valid: false }, + "Non-Product page returns partial result" + ); + + Assert.deepEqual( + ShoppingProduct.fromURL(new URL("https://walmart.com/ip/926485654")), + { + host: "walmart.com", + sitename: "walmart", + tld: "com", + id: "926485654", + valid: true, + }, + "Valid Product Url returns a full result object" + ); + + Assert.deepEqual( + ShoppingProduct.fromURL(new URL("http://walmart.com/ip/926485654")), + { + host: "walmart.com", + sitename: "walmart", + tld: "com", + id: "926485654", + valid: true, + }, + "Protocol is not checked" + ); + + Assert.deepEqual( + ShoppingProduct.fromURL(new URL("https://amazon.fr/product/dp/ABCDEFG123")), + { + host: "amazon.fr", + sitename: "amazon", + tld: "fr", + id: "ABCDEFG123", + valid: true, + }, + "Valid French Product Url returns a full result object" + ); + + Assert.deepEqual( + ShoppingProduct.fromURL(new URL("https://amazon.de/product/dp/ABCDEFG123")), + { + host: "amazon.de", + sitename: "amazon", + tld: "de", + id: "ABCDEFG123", + valid: true, + }, + "Valid German Product Url returns a full result object" + ); +}); + +add_task(function test_product_isProduct() { + let product = { + host: "walmart.com", + sitename: "walmart", + tld: "com", + id: "926485654", + valid: true, + }; + Assert.equal( + ShoppingProduct.isProduct(product), + true, + "Passing a Product object returns true" + ); + Assert.equal( + ShoppingProduct.isProduct({ host: "walmart.com", sitename: "walmart" }), + false, + "Passing an incomplete ShoppingProduct object returns false" + ); + Assert.equal( + ShoppingProduct.isProduct(), + false, + "Passing nothing returns false" + ); +}); + +add_task(function test_amazon_product_urls() { + let product; + let url_com = new URL( + "https://www.amazon.com/Furmax-Electric-Adjustable-Standing-Computer/dp/B09TJGHL5F/" + ); + let url_ca = new URL( + "https://www.amazon.ca/JBL-Flip-Essential-Waterproof-Bluetooth/dp/B0C3NNGWFN/" + ); + let url_uk = new URL( + "https://www.amazon.co.uk/placeholder_title/dp/B0B8KGPHS7/" + ); + let url_content = new URL("https://www.amazon.com/stores/node/20648519011"); + + product = ShoppingProduct.fromURL(url_com); + Assert.equal(ShoppingProduct.isProduct(product), true, "Url is a product"); + Assert.equal(product.id, "B09TJGHL5F", "Product id was found in Url"); + + product = ShoppingProduct.fromURL(url_ca); + Assert.equal( + ShoppingProduct.isProduct(product), + false, + "Url is not a supported tld" + ); + + product = ShoppingProduct.fromURL(url_uk); + Assert.equal( + ShoppingProduct.isProduct(product), + false, + "Url is not a supported tld" + ); + + product = ShoppingProduct.fromURL(url_content); + Assert.equal( + ShoppingProduct.isProduct(product), + false, + "Url is not a product" + ); +}); + +add_task(function test_walmart_product_urls() { + let product; + let url_com = new URL( + "https://www.walmart.com/ip/Kent-Bicycles-29-Men-s-Trouvaille-Mountain-Bike-Medium-Black-and-Taupe/823391155" + ); + let url_ca = new URL( + "https://www.walmart.ca/en/ip/cherries-jumbo/6000187473587" + ); + let url_content = new URL( + "https://www.walmart.com/browse/food/grilling-foods/976759_1567409_8808777" + ); + + product = ShoppingProduct.fromURL(url_com); + Assert.equal(ShoppingProduct.isProduct(product), true, "Url is a product"); + Assert.equal(product.id, "823391155", "Product id was found in Url"); + + product = ShoppingProduct.fromURL(url_ca); + Assert.equal( + ShoppingProduct.isProduct(product), + false, + "Url is not a valid tld" + ); + + product = ShoppingProduct.fromURL(url_content); + Assert.equal( + ShoppingProduct.isProduct(product), + false, + "Url is not a product" + ); +}); + +add_task(function test_bestbuy_product_urls() { + let product; + let url_com = new URL( + "https://www.bestbuy.com/site/ge-profile-ultrafast-4-8-cu-ft-large-capacity-all-in-one-washer-dryer-combo-with-ventless-heat-pump-technology-carbon-graphite/6530134.p?skuId=6530134" + ); + let url_ca = new URL( + "https://www.bestbuy.ca/en-ca/product/segway-ninebot-kickscooter-f40-electric-scooter-40km-range-30km-h-top-speed-dark-grey/15973012" + ); + let url_content = new URL( + "https://www.bestbuy.com/site/home-appliances/major-appliances-sale-event/pcmcat321600050000.c" + ); + + product = ShoppingProduct.fromURL(url_com); + Assert.equal(ShoppingProduct.isProduct(product), true, "Url is a product"); + Assert.equal(product.id, "6530134.p", "Product id was found in Url"); + + product = ShoppingProduct.fromURL(url_ca); + Assert.equal( + ShoppingProduct.isProduct(product), + false, + "Url is not a valid tld" + ); + + product = ShoppingProduct.fromURL(url_content); + Assert.equal( + ShoppingProduct.isProduct(product), + false, + "Url is not a product" + ); +}); + +add_task(function test_isProductURL() { + let product_string = + "https://www.amazon.com/Furmax-Electric-Adjustable-Standing-Computer/dp/B09TJGHL5F/"; + let product_url = new URL(product_string); + let product_uri = Services.io.newURI(product_string); + Assert.equal( + isProductURL(product_url), + true, + "Passing a product URL returns true" + ); + Assert.equal( + isProductURL(product_uri), + true, + "Passing a product URI returns true" + ); + + let content_string = + "https://www.walmart.com/browse/food/grilling-foods/976759_1567409_8808777"; + let content_url = new URL(content_string); + let content_uri = Services.io.newURI(content_string); + Assert.equal( + isProductURL(content_url), + false, + "Passing a content URL returns false" + ); + Assert.equal( + isProductURL(content_uri), + false, + "Passing a content URI returns false" + ); + + Assert.equal(isProductURL(), false, "Passing nothing returns false"); + + Assert.equal(isProductURL(1234), false, "Passing a number returns false"); + + Assert.equal( + isProductURL("1234"), + false, + "Passing a junk string returns false" + ); +}); + +add_task(function test_new_ShoppingProduct() { + let product_string = + "https://www.amazon.com/Furmax-Electric-Adjustable-Standing-Computer/dp/B09TJGHL5F/"; + let product_url = new URL(product_string); + let product_uri = Services.io.newURI(product_string); + let productURL = new ShoppingProduct(product_url); + Assert.equal( + productURL.isProduct(), + true, + "Passing a product URL returns a valid product" + ); + let productURI = new ShoppingProduct(product_uri); + Assert.equal( + productURI.isProduct(), + true, + "Passing a product URI returns a valid product" + ); +}); diff --git a/toolkit/components/shopping/test/xpcshell/test_product_validator.js b/toolkit/components/shopping/test/xpcshell/test_product_validator.js new file mode 100644 index 0000000000..5aab07dbf5 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/test_product_validator.js @@ -0,0 +1,91 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; +/* global loadJSONfromFile */ + +const { + ANALYSIS_RESPONSE_SCHEMA, + ANALYSIS_REQUEST_SCHEMA, + RECOMMENDATIONS_RESPONSE_SCHEMA, + RECOMMENDATIONS_REQUEST_SCHEMA, +} = ChromeUtils.importESModule( + "chrome://global/content/shopping/ProductConfig.mjs" +); + +const { ProductValidator } = ChromeUtils.importESModule( + "chrome://global/content/shopping/ProductValidator.sys.mjs" +); + +add_task(async function test_validate_analysis() { + const json = await loadJSONfromFile("data/analysis_response.json"); + let valid = await ProductValidator.validate(json, ANALYSIS_RESPONSE_SCHEMA); + + Assert.equal(valid, true, "Analysis JSON is valid"); +}); + +add_task(async function test_validate_analysis_invalid() { + const json = await loadJSONfromFile("data/invalid_analysis_response.json"); + let valid = await ProductValidator.validate(json, ANALYSIS_RESPONSE_SCHEMA); + + Assert.equal(valid, false, "Analysis JSON is invalid"); +}); + +add_task(async function test_validate_recommendations() { + const json = await loadJSONfromFile("data/recommendations_response.json"); + let valid = await ProductValidator.validate( + json, + RECOMMENDATIONS_RESPONSE_SCHEMA + ); + + Assert.equal(valid, true, "Recommendations JSON is valid"); +}); + +add_task(async function test_validate_recommendations_invalid() { + const json = await loadJSONfromFile( + "data/invalid_recommendations_response.json" + ); + let valid = await ProductValidator.validate( + json, + RECOMMENDATIONS_RESPONSE_SCHEMA + ); + + Assert.equal(valid, false, "Recommendations JSON is invalid"); +}); + +add_task(async function test_validate_analysis() { + const json = await loadJSONfromFile("data/analysis_request.json"); + let valid = await ProductValidator.validate(json, ANALYSIS_REQUEST_SCHEMA); + + Assert.equal(valid, true, "Analysis JSON is valid"); +}); + +add_task(async function test_validate_analysis_invalid() { + const json = await loadJSONfromFile("data/invalid_analysis_request.json"); + let valid = await ProductValidator.validate(json, ANALYSIS_REQUEST_SCHEMA); + + Assert.equal(valid, false, "Analysis JSON is invalid"); +}); + +add_task(async function test_validate_recommendations() { + const json = await loadJSONfromFile("data/recommendations_request.json"); + let valid = await ProductValidator.validate( + json, + RECOMMENDATIONS_REQUEST_SCHEMA + ); + + Assert.equal(valid, true, "Recommendations JSON is valid"); +}); + +add_task(async function test_validate_recommendations_invalid() { + const json = await loadJSONfromFile( + "data/invalid_recommendations_request.json" + ); + let valid = await ProductValidator.validate( + json, + RECOMMENDATIONS_REQUEST_SCHEMA + ); + + Assert.equal(valid, false, "Recommendations JSON is invalid"); +}); diff --git a/toolkit/components/shopping/test/xpcshell/xpcshell.toml b/toolkit/components/shopping/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..a95d53dca3 --- /dev/null +++ b/toolkit/components/shopping/test/xpcshell/xpcshell.toml @@ -0,0 +1,38 @@ +[DEFAULT] +head = "head.js" +prefs = [ + "toolkit.shopping.environment='test'", + "toolkit.shopping.ohttpConfigURL=''", + "toolkit.shopping.ohttpRelayURL=''", +] + +support-files = [ + "data/analysis_response.json", + "data/recommendations_response.json", + "data/invalid_analysis_response.json", + "data/invalid_recommendations_response.json", + "data/analysis_request.json", + "data/recommendations_request.json", + "data/invalid_analysis_request.json", + "data/invalid_recommendations_request.json", + "data/service_unavailable.json", + "data/bad_request.json", + "data/unprocessable_entity.json", + "data/needs_analysis_response.json", + "data/attribution_response.json", + "data/image.jpg", + "data/report_response.json", + "data/analysis_status_completed_response.json", + "data/analysis_status_in_progress_response.json", + "data/analysis_status_pending_response.json", + "data/analyze_pending.json", + "data/too_many_requests.json", +] + +["test_fetchImage.js"] + +["test_product.js"] + +["test_product_urls.js"] + +["test_product_validator.js"] |