diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js index 6d825ca905c..7e29f12c202 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.js @@ -12,10 +12,11 @@ import isSoldOut from "./isSoldOut"; * @param {Object} variantPriceInfo The result of calling getPriceRange for this price or all child prices * @param {String} shopCurrencyCode The shop currency code for the shop to which this product belongs * @param {Object} variantMedia Media for this specific variant + * @param {Object} variantInventory Inventory flags for this variant * @private * @returns {Object} The transformed variant */ -export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, variantMedia) { +export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, variantMedia, variantInventory) { const primaryImage = variantMedia.find(({ toGrid }) => toGrid === 1) || null; return { @@ -26,8 +27,8 @@ export function xformVariant(variant, variantPriceInfo, shopCurrencyCode, varian index: variant.index || 0, inventoryManagement: !!variant.inventoryManagement, inventoryPolicy: !!variant.inventoryPolicy, - isLowQuantity: !!variant.isLowQuantity, - isSoldOut: !!variant.isSoldOut, + isLowQuantity: variantInventory.isLowQuantity, + isSoldOut: variantInventory.isSoldOut, length: variant.length, lowInventoryWarningThreshold: variant.lowInventoryWarningThreshold, media: variantMedia, @@ -95,22 +96,35 @@ export async function xformProduct({ collections, product, shop, variants }) { .map((variant) => { const variantOptions = options.get(variant._id); let priceInfo; + let variantInventory; if (variantOptions) { const optionPrices = variantOptions.map((option) => option.price); priceInfo = getPriceRange(optionPrices, shopCurrencyInfo); + variantInventory = { + isLowQuantity: isLowQuantity(variantOptions), + isSoldOut: isSoldOut(variantOptions) + }; } else { priceInfo = getPriceRange([variant.price], shopCurrencyInfo); + variantInventory = { + isLowQuantity: isLowQuantity([variant]), + isSoldOut: isSoldOut([variant]) + }; } prices.push(priceInfo.min, priceInfo.max); const variantMedia = catalogProductMedia.filter((media) => media.variantId === variant._id); - const newVariant = xformVariant(variant, priceInfo, shopCurrencyCode, variantMedia); + const newVariant = xformVariant(variant, priceInfo, shopCurrencyCode, variantMedia, variantInventory); if (variantOptions) { newVariant.options = variantOptions.map((option) => { const optionMedia = catalogProductMedia.filter((media) => media.variantId === option._id); - return xformVariant(option, getPriceRange([option.price], shopCurrencyInfo), shopCurrencyCode, optionMedia); + const optionInventory = { + isLowQuantity: isLowQuantity([option]), + isSoldOut: isSoldOut([option]) + }; + return xformVariant(option, getPriceRange([option.price], shopCurrencyInfo), shopCurrencyCode, optionMedia, optionInventory); }); } return newVariant; diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js index ab9ed6939ad..582889fca40 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/createCatalogProduct.test.js @@ -288,7 +288,7 @@ const mockCatalogProduct = { index: 0, inventoryManagement: true, inventoryPolicy: false, - isLowQuantity: true, + isLowQuantity: false, isSoldOut: false, length: 0, lowInventoryWarningThreshold: 0, @@ -311,7 +311,7 @@ const mockCatalogProduct = { index: 0, inventoryManagement: true, inventoryPolicy: true, - isLowQuantity: true, + isLowQuantity: false, isSoldOut: false, length: 2, lowInventoryWarningThreshold: 0, diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/updateCatalogProductInventoryStatus.js b/imports/plugins/core/catalog/server/no-meteor/utils/updateCatalogProductInventoryStatus.js index aaee176ae08..36cb8c13f17 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/updateCatalogProductInventoryStatus.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/updateCatalogProductInventoryStatus.js @@ -1,4 +1,5 @@ import Logger from "@reactioncommerce/logger"; +import _ from "lodash"; import isBackorder from "./isBackorder"; import isLowQuantity from "./isLowQuantity"; import isSoldOut from "./isSoldOut"; @@ -13,6 +14,10 @@ import isSoldOut from "./isSoldOut"; * @return {Promise} true on success, false on failure */ export default async function updateCatalogProductInventoryStatus(productId, collections) { + const baseKey = "product"; + const topVariants = new Map(); + const options = new Map(); + const { Catalog, Products } = collections; const catalogItem = await Catalog.findOne({ "product.productId": productId }); @@ -21,33 +26,74 @@ export default async function updateCatalogProductInventoryStatus(productId, col return false; } - const catalogProduct = catalogItem.product; - const variants = await Products.find({ ancestors: productId }).toArray(); - const update = { + const modifier = { "product.isSoldOut": isSoldOut(variants), "product.isBackorder": isBackorder(variants), "product.isLowQuantity": isLowQuantity(variants) }; - // Only apply changes if one of these fields have changed - if ( - update["product.isSoldOut"] !== catalogProduct.isSoldOut || - update["product.isBackorder"] !== catalogProduct.isBackorder || - update["product.isLowQuantity"] !== catalogProduct.isLowQuantity - ) { - const result = await Catalog.updateOne( - { - "product.productId": productId - }, - { - $set: update + variants.forEach((variant) => { + if (variant.ancestors.length === 2) { + const parentId = variant.ancestors[1]; + if (options.has(parentId)) { + options.get(parentId).push(variant); + } else { + options.set(parentId, [variant]); } + } else { + topVariants.set(variant._id, variant); + } + }); + + const topVariantsFromCatalogItem = catalogItem.product.variants; + + topVariantsFromCatalogItem.forEach((variant, topVariantIndex) => { + const catalogVariantOptions = variant.options || []; + const topVariantFromProductsCollection = topVariants.get(variant._id); + const variantOptionsFromProductsCollection = options.get(variant._id); + const catalogVariantOptionsMap = new Map(); + + catalogVariantOptions.forEach((catalogVariantOption) => { + catalogVariantOptionsMap.set(catalogVariantOption._id, catalogVariantOption); + }); + + // We only want the variant options that are currently published to the catalog. + // We need to be careful, not to publish variant or options to the catalog + // that an operator may not wish to be published yet. + const variantOptions = _.intersectionWith( + variantOptionsFromProductsCollection, // array to filter + catalogVariantOptions, // Items to exclude + ({ _id: productVariantId }, { _id: catalogItemVariantOptionId }) => ( + // Exclude options from the products collection that aren't in the catalog collection + productVariantId === catalogItemVariantOptionId + ) ); - return result && result.result && result.result.ok === 1; - } + if (variantOptions) { + // Create a modifier for a variant and it's options + modifier[`${baseKey}.variants.${topVariantIndex}.isSoldOut`] = isSoldOut(variantOptions); + modifier[`${baseKey}.variants.${topVariantIndex}.isLowQuantity`] = isLowQuantity(variantOptions); + modifier[`${baseKey}.variants.${topVariantIndex}.isBackorder`] = isBackorder(variantOptions); + + variantOptions.forEach((option, optionIndex) => { + modifier[`${baseKey}.variants.${topVariantIndex}.options.${optionIndex}.isSoldOut`] = isSoldOut([option]); + modifier[`${baseKey}.variants.${topVariantIndex}.options.${optionIndex}.isLowQuantity`] = isLowQuantity([option]); + modifier[`${baseKey}.variants.${topVariantIndex}.options.${optionIndex}.isBackorder`] = isBackorder([option]); + }); + } else { + // Create a modifier for a top level variant only + modifier[`${baseKey}.variants.${topVariantIndex}.isSoldOut`] = isSoldOut([topVariantFromProductsCollection]); + modifier[`${baseKey}.variants.${topVariantIndex}.isLowQuantity`] = isLowQuantity([topVariantFromProductsCollection]); + modifier[`${baseKey}.variants.${topVariantIndex}.isBackorder`] = isBackorder([topVariantFromProductsCollection]); + } + }); + + const result = await Catalog.updateOne( + { "product.productId": productId }, + { $set: modifier } + ); - return false; + return (result && result.result && result.result.ok === 1) || false; } diff --git a/imports/plugins/core/catalog/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js b/imports/plugins/core/catalog/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js index cbe080bf99e..7143601ba59 100644 --- a/imports/plugins/core/catalog/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js +++ b/imports/plugins/core/catalog/server/no-meteor/utils/updateCatalogProductInventoryStatus.test.js @@ -168,7 +168,7 @@ const mockProduct = { twitterMsg: "twitterMessage", type: "product-simple", updatedAt, - mockVariants, + variants: mockVariants, vendor: "vendor", weight: 15.6, width: 8.4 @@ -211,15 +211,7 @@ test("expect true if a product's inventory has changed and is updated in the cat expect(spec).toBe(true); }); -test("expect false if a product's inventory did not change and is not updated in the catalog collection", async () => { - mockCollections.Catalog.findOne.mockReturnValueOnce(Promise.resolve(mockCatalogItem)); - mockCollections.Products.toArray.mockReturnValueOnce(Promise.resolve(mockVariants)); - mockIsSoldOut.mockReturnValueOnce(false); - const spec = await updateCatalogProductInventoryStatus(mockProduct, mockCollections); - expect(spec).toBe(false); -}); - -test("expect false if a product's catalog item does not exsit", async () => { +test("expect false if a product's catalog item does not exist", async () => { mockCollections.Catalog.findOne.mockReturnValueOnce(Promise.resolve(undefined)); const spec = await updateCatalogProductInventoryStatus(mockProduct, mockCollections); expect(spec).toBe(false); diff --git a/imports/plugins/core/versions/server/migrations/48_catalog_variant_inventory.js b/imports/plugins/core/versions/server/migrations/48_catalog_variant_inventory.js new file mode 100644 index 00000000000..bf4ce9bffa1 --- /dev/null +++ b/imports/plugins/core/versions/server/migrations/48_catalog_variant_inventory.js @@ -0,0 +1,15 @@ +import { Migrations } from "meteor/percolate:migrations"; +import { Catalog, Products } from "/lib/collections"; +import { convertCatalogItemVariants } from "../util/convert48"; +import findAndConvertInBatches from "../util/findAndConvertInBatches"; + +Migrations.add({ + version: 48, + up() { + // Catalog + findAndConvertInBatches({ + collection: Catalog, + converter: (catalogItem) => convertCatalogItemVariants(catalogItem, { Products }) + }); + } +}); diff --git a/imports/plugins/core/versions/server/migrations/index.js b/imports/plugins/core/versions/server/migrations/index.js index a09af0629d6..03082c697d8 100644 --- a/imports/plugins/core/versions/server/migrations/index.js +++ b/imports/plugins/core/versions/server/migrations/index.js @@ -45,3 +45,4 @@ import "./44_tax_rates_pkg"; import "./45_tax_schema_changes"; import "./46_cart_item_props"; import "./47_order_ref"; +import "./48_catalog_variant_inventory"; diff --git a/imports/plugins/core/versions/server/util/convert48.js b/imports/plugins/core/versions/server/util/convert48.js new file mode 100644 index 00000000000..6170834cb7b --- /dev/null +++ b/imports/plugins/core/versions/server/util/convert48.js @@ -0,0 +1,153 @@ +import _ from "lodash"; + +/** + * + * @method getProductQuantity + * @summary Get the number of product variants still avalible to purchase. This function can + * take only a top level variant object as a param to return the product's quantity. + * This method can also take a top level variant and an array of product variant options as + * params return the product's quantity. + * @memberof Catalog + * @param {Object} variant - A top level product variant object. + * @param {Object[]} variants - Array of product variant option objects. + * @return {number} Variant quantity + */ +export default function getProductQuantity(variant, variants = []) { + const options = variants.filter((option) => option.ancestors[1] === variant._id); + if (options && options.length) { + return options.reduce((sum, option) => sum + option.inventoryQuantity || 0, 0); + } + return variant.inventoryQuantity || 0; +} + + +/** + * @method isSoldOut + * @summary If all the product variants have a quantity of 0 return `true`. + * @memberof Catalog + * @param {Object[]} variants - Array with top-level variants + * @return {Boolean} true if quantity is zero. + */ +function isSoldOut(variants) { + const results = variants.map((variant) => { + const quantity = getProductQuantity(variant, variants); + return variant.inventoryManagement && quantity <= 0; + }); + return results.every((result) => result); +} + +/** + * @method isLowQuantity + * @summary If at least one of the product variants quantity is less than the low inventory threshold return `true`. + * @memberof Catalog + * @param {Object[]} variants - Array of child variants + * @return {boolean} low quantity or not + */ +function isLowQuantity(variants) { + const threshold = variants && variants.length && variants[0].lowInventoryWarningThreshold; + const results = variants.map((variant) => { + const quantity = getProductQuantity(variant, variants); + if (variant.inventoryManagement && quantity) { + return quantity <= threshold; + } + return false; + }); + return results.some((result) => result); +} + +/** + * @param {Object} item The catalog item to transform + * @param {Object} collections The catalog item to transform + * @returns {Object} The converted item document + */ +export function convertCatalogItemVariants(item, collections) { + const { Products } = collections; + + // Get all variants of the product. + // All variants are needed as we need to match the currently published variants with + // their counterparts from the products collection. We don't want to the inventory numbers + // to be invalid just because a product has been set to not visible while that change has + // not yet been published to the catalog. + // The catalog will be used as the source of truth for the variants and options. + const variants = Products.find({ + ancestors: item.product._id + }).fetch(); + + const topVariants = new Map(); + const options = new Map(); + + variants.forEach((variant) => { + if (variant.ancestors.length === 2) { + const parentId = variant.ancestors[1]; + if (options.has(parentId)) { + options.get(parentId).push(variant); + } else { + options.set(parentId, [variant]); + } + } else { + topVariants.set(variant._id, variant); + } + }); + + const catalogProductVariants = item.product.variants.map((variant) => { + const catalogVariantOptions = variant.options || []; + const topVariantFromProductsCollection = topVariants.get(variant._id); + const variantOptionsFromProductsCollection = options.get(variant._id); + const catalogVariantOptionsMap = new Map(); + + catalogVariantOptions.forEach((catalogVariantOption) => { + catalogVariantOptionsMap.set(catalogVariantOption._id, catalogVariantOption); + }); + + // We only want the variant options that are currently published to the catalog. + // We need to be careful, not to publish variant or options to the catalog + // that an operator may not wish to be published yet. + const variantOptions = _.intersectionWith( + variantOptionsFromProductsCollection, // array to filter + catalogVariantOptions, // Items to exclude + ({ _id: productVariantId }, { _id: catalogItemVariantOptionId }) => ( + // Exclude options from the products collection that aren't in the catalog collection + productVariantId === catalogItemVariantOptionId + ) + ); + + let updatedVariantFields; + if (variantOptions) { + // For variants with options, update the inventory flags for the top-level variant and options + updatedVariantFields = { + isLowQuantity: isLowQuantity(variantOptions), + isSoldOut: isSoldOut(variantOptions), + options: variantOptions.map((option) => ({ + ...catalogVariantOptionsMap.get(option._id), + isLowQuantity: isLowQuantity([option]), + isSoldOut: isSoldOut([option]) + })) + }; + } else { + // For variants WITHOUT options, update the inventory flags for the top-level variant only + updatedVariantFields = { + isLowQuantity: isLowQuantity([topVariantFromProductsCollection]), + isSoldOut: isSoldOut([topVariantFromProductsCollection]) + }; + } + + return { + ...variant, + ...updatedVariantFields + }; + }); + + const catalogProduct = { + ...item.product, + variants: catalogProductVariants + }; + + const doc = { + _id: item._id, + product: catalogProduct, + shopId: item.shopId, + createdAt: item.createdAt + }; + + return doc; +} diff --git a/imports/plugins/core/versions/server/util/convert48.test.js b/imports/plugins/core/versions/server/util/convert48.test.js new file mode 100644 index 00000000000..2d0b7c1babd --- /dev/null +++ b/imports/plugins/core/versions/server/util/convert48.test.js @@ -0,0 +1,651 @@ +import mockContext from "/imports/test-utils/helpers/mockContext"; +import { convertCatalogItemVariants } from "./convert48"; + +const mockCollections = { ...mockContext.collections }; + +const internalShopId = "123"; +const internalCatalogItemId = "999"; +const internalCatalogProductId = "999"; +const internalVariantIds = ["875", "874"]; + +const createdAt = new Date("2018-04-16T15:34:28.043Z"); +const updatedAt = new Date("2018-04-17T15:34:28.043Z"); + +const mockVariants = [ + // This top-level variant should not be considered in the update of current catalog item + // variants / options when updating inventory. It's not published (not in the catalog) + // and there for cannot be considered, lest we unintentionally publish products + // that are not yet ready to be published. + { + _id: "29805-not-publish-top-variant", + ancestors: [internalCatalogProductId], + barcode: "barcode", + createdAt, + compareAtPrice: 1100, + height: 0, + index: 0, + inventoryManagement: true, + inventoryPolicy: false, + inventoryQuantity: 100, + isLowQuantity: true, + isSoldOut: false, + isDeleted: false, + isVisible: true, + length: 0, + lowInventoryWarningThreshold: 0, + metafields: [ + { + value: "value", + namespace: "namespace", + description: "description", + valueType: "valueType", + scope: "scope", + key: "key" + } + ], + minOrderQuantity: 0, + optionTitle: "Untitled Option", + originCountry: "US", + price: 0, + shopId: internalShopId, + sku: "sku", + taxable: true, + taxCode: "0000", + taxDescription: "taxDescription", + title: "Small Concrete Pizza", + updatedAt, + variantId: internalVariantIds[0], + weight: 0, + width: 0 + }, + // This variant-option should not be considered in the update of current catalog item + // variants / options when updating inventory. It's not published (not in the catalog) + // and there for cannot be considered, lest we unintentionally publish products + // that are not yet ready to be published. + { + _id: "1234-not-yet-published", + ancestors: [internalCatalogProductId, internalVariantIds[0]], + barcode: "barcode", + createdAt, + height: 2, + index: 0, + inventoryManagement: true, + inventoryPolicy: true, + inventoryQuantity: 1000, + isLowQuantity: true, + isSoldOut: false, + isDeleted: false, + isVisible: true, + length: 2, + lowInventoryWarningThreshold: 0, + metafields: [ + { + value: "value", + namespace: "namespace", + description: "description", + valueType: "valueType", + scope: "scope", + key: "key" + } + ], + minOrderQuantity: 0, + optionTitle: "Awesome Soft Bike", + originCountry: "US", + price: 992.0, + shopId: internalShopId, + sku: "sku", + taxable: true, + taxCode: "0000", + taxDescription: "taxDescription", + title: "One pound bag", + updatedAt, + variantId: internalVariantIds[1], + weight: 2, + width: 2 + }, + // Variant SHOULD be used for the inventory update + { + _id: internalVariantIds[0], + ancestors: [internalCatalogProductId], + barcode: "barcode", + createdAt, + compareAtPrice: 1100, + height: 0, + index: 0, + inventoryManagement: true, + inventoryPolicy: false, + inventoryQuantity: 100, + isLowQuantity: true, + isSoldOut: false, + isDeleted: false, + isVisible: true, + length: 0, + lowInventoryWarningThreshold: 0, + metafields: [ + { + value: "value", + namespace: "namespace", + description: "description", + valueType: "valueType", + scope: "scope", + key: "key" + } + ], + minOrderQuantity: 0, + optionTitle: "Untitled Option", + originCountry: "US", + price: 0, + shopId: internalShopId, + sku: "sku", + taxable: true, + taxCode: "0000", + taxDescription: "taxDescription", + title: "Small Concrete Pizza", + updatedAt, + variantId: internalVariantIds[0], + weight: 0, + width: 0 + }, + // Variant option SHOULD be used for the inventory update + { + _id: internalVariantIds[1], + ancestors: [internalCatalogProductId, internalVariantIds[0]], + barcode: "barcode", + createdAt, + height: 2, + index: 0, + inventoryManagement: true, + inventoryPolicy: true, + inventoryQuantity: 0, + isLowQuantity: true, + isSoldOut: false, + isDeleted: false, + isVisible: true, + length: 2, + lowInventoryWarningThreshold: 0, + metafields: [ + { + value: "value", + namespace: "namespace", + description: "description", + valueType: "valueType", + scope: "scope", + key: "key" + } + ], + minOrderQuantity: 0, + optionTitle: "Awesome Soft Bike", + originCountry: "US", + price: 992.0, + shopId: internalShopId, + sku: "sku", + taxable: true, + taxCode: "0000", + taxDescription: "taxDescription", + title: "One pound bag", + updatedAt, + variantId: internalVariantIds[1], + weight: 2, + width: 2 + } +]; + +const mockCatalogProductBefore = { + _id: "999", + barcode: "barcode", + createdAt, + description: "description", + height: 11.23, + isBackorder: false, + isDeleted: false, + isLowQuantity: false, + isSoldOut: true, + isTaxable: false, + isVisible: false, + length: 5.67, + lowInventoryWarningThreshold: 2, + media: [{ + URLs: { + large: "large/path/to/image.jpg", + medium: "medium/path/to/image.jpg", + original: "image/path/to/image.jpg", + small: "small/path/to/image.jpg", + thumbnail: "thumbnail/path/to/image.jpg" + }, + priority: 1, + productId: "999", + toGrid: 1, + variantId: "874" + }], + metaDescription: "metaDescription", + metafields: [{ + description: "description", + key: "key", + namespace: "namespace", + scope: "scope", + value: "value", + valueType: "valueType" + }], + originCountry: "originCountry", + pageTitle: "pageTitle", + parcel: { + containers: "containers", + height: 6.66, + length: 4.44, + weight: 7.77, + width: 5.55 + }, + price: { + max: 5.99, + min: 2.99, + range: "2.99 - 5.99" + }, + pricing: { + USD: { + compareAtPrice: null, + displayPrice: "$992.00", + maxPrice: 992, + minPrice: 992, + price: null + } + }, + primaryImage: { + URLs: { + large: "large/path/to/image.jpg", + medium: "medium/path/to/image.jpg", + original: "image/path/to/image.jpg", + small: "small/path/to/image.jpg", + thumbnail: "thumbnail/path/to/image.jpg" + }, + priority: 1, + productId: "999", + toGrid: 1, + variantId: "874" + }, + productId: "999", + productType: "productType", + shopId: "123", + sku: "ABC123", + slug: "fake-product", + socialMetadata: [{ + message: "twitterMessage", + service: "twitter" + }, { + message: "facebookMessage", + service: "facebook" + }, { + message: "googlePlusMessage", + service: "googleplus" + }, { + message: "pinterestMessage", + service: "pinterest" + }], + supportedFulfillmentTypes: ["shipping"], + tagIds: ["923", "924"], + taxCode: "taxCode", + taxDescription: "taxDescription", + title: "Fake Product Title", + type: "product-simple", + updatedAt, + variants: [{ + _id: "875", + barcode: "barcode", + createdAt, + height: 0, + index: 0, + inventoryManagement: true, + inventoryPolicy: false, + inventoryQuantity: 100, + isLowQuantity: false, + isSoldOut: false, + isTaxable: true, + length: 0, + lowInventoryWarningThreshold: 0, + media: [], + metafields: [{ + description: "description", + key: "key", + namespace: "namespace", + scope: "scope", + value: "value", + valueType: "valueType" + }], + minOrderQuantity: 0, + optionTitle: "Untitled Option", + options: [{ + _id: "874", + barcode: "barcode", + createdAt, + height: 2, + index: 0, + inventoryManagement: true, + inventoryPolicy: true, + inventoryQuantity: 0, + isLowQuantity: false, + isSoldOut: false, + isTaxable: true, + length: 2, + lowInventoryWarningThreshold: 0, + media: [{ + URLs: { + large: "large/path/to/image.jpg", + medium: "medium/path/to/image.jpg", + original: "image/path/to/image.jpg", + small: "small/path/to/image.jpg", + thumbnail: "thumbnail/path/to/image.jpg" + }, + priority: 1, + productId: "999", + toGrid: 1, + variantId: "874" + }], + metafields: [{ + description: "description", + key: "key", + namespace: "namespace", + scope: "scope", + value: "value", + valueType: "valueType" + }], + minOrderQuantity: 0, + optionTitle: "Awesome Soft Bike", + originCountry: "US", + price: 992, + pricing: { + USD: { + compareAtPrice: null, + displayPrice: "$992.00", + maxPrice: 992, + minPrice: 992, + price: 992 + } + }, + primaryImage: { + URLs: { + large: "large/path/to/image.jpg", + medium: "medium/path/to/image.jpg", + original: "image/path/to/image.jpg", + small: "small/path/to/image.jpg", + thumbnail: "thumbnail/path/to/image.jpg" + }, + priority: 1, + productId: "999", + toGrid: 1, + variantId: "874" + }, + shopId: "123", + sku: "sku", + taxCode: "0000", + taxDescription: "taxDescription", + title: "One pound bag", + updatedAt, + variantId: "874", + weight: 2, + width: 2 + }], + originCountry: "US", + price: 0, + pricing: { + USD: { + compareAtPrice: 1100, + displayPrice: "$992.00", + maxPrice: 992, + minPrice: 992, + price: 0 + } + }, + primaryImage: null, + shopId: "123", + sku: "sku", + taxCode: "0000", + taxDescription: "taxDescription", + title: "Small Concrete Pizza", + updatedAt, + variantId: "875", + weight: 0, + width: 0 + }], + vendor: "vendor", + weight: 15.6, + width: 8.4 +}; + +const mockCatalogProductAfter = { + _id: "999", + barcode: "barcode", + createdAt, + description: "description", + height: 11.23, + isBackorder: false, + isDeleted: false, + isLowQuantity: false, + isSoldOut: true, + isTaxable: false, + isVisible: false, + length: 5.67, + lowInventoryWarningThreshold: 2, + media: [{ + URLs: { + large: "large/path/to/image.jpg", + medium: "medium/path/to/image.jpg", + original: "image/path/to/image.jpg", + small: "small/path/to/image.jpg", + thumbnail: "thumbnail/path/to/image.jpg" + }, + priority: 1, + productId: "999", + toGrid: 1, + variantId: "874" + }], + metaDescription: "metaDescription", + metafields: [{ + description: "description", + key: "key", + namespace: "namespace", + scope: "scope", + value: "value", + valueType: "valueType" + }], + originCountry: "originCountry", + pageTitle: "pageTitle", + parcel: { + containers: "containers", + height: 6.66, + length: 4.44, + weight: 7.77, + width: 5.55 + }, + price: { + max: 5.99, + min: 2.99, + range: "2.99 - 5.99" + }, + pricing: { + USD: { + compareAtPrice: null, + displayPrice: "$992.00", + maxPrice: 992, + minPrice: 992, + price: null + } + }, + primaryImage: { + URLs: { + large: "large/path/to/image.jpg", + medium: "medium/path/to/image.jpg", + original: "image/path/to/image.jpg", + small: "small/path/to/image.jpg", + thumbnail: "thumbnail/path/to/image.jpg" + }, + priority: 1, + productId: "999", + toGrid: 1, + variantId: "874" + }, + productId: "999", + productType: "productType", + shopId: "123", + sku: "ABC123", + slug: "fake-product", + socialMetadata: [{ + message: "twitterMessage", + service: "twitter" + }, { + message: "facebookMessage", + service: "facebook" + }, { + message: "googlePlusMessage", + service: "googleplus" + }, { + message: "pinterestMessage", + service: "pinterest" + }], + supportedFulfillmentTypes: ["shipping"], + tagIds: ["923", "924"], + taxCode: "taxCode", + taxDescription: "taxDescription", + title: "Fake Product Title", + type: "product-simple", + updatedAt, + variants: [{ + _id: "875", + barcode: "barcode", + createdAt, + height: 0, + index: 0, + inventoryManagement: true, + inventoryPolicy: false, + inventoryQuantity: 100, + isLowQuantity: false, + isSoldOut: true, + isTaxable: true, + length: 0, + lowInventoryWarningThreshold: 0, + media: [], + metafields: [{ + description: "description", + key: "key", + namespace: "namespace", + scope: "scope", + value: "value", + valueType: "valueType" + }], + minOrderQuantity: 0, + optionTitle: "Untitled Option", + options: [{ + _id: "874", + barcode: "barcode", + createdAt, + height: 2, + index: 0, + inventoryManagement: true, + inventoryPolicy: true, + inventoryQuantity: 0, + isLowQuantity: false, + isSoldOut: true, + isTaxable: true, + length: 2, + lowInventoryWarningThreshold: 0, + media: [{ + URLs: { + large: "large/path/to/image.jpg", + medium: "medium/path/to/image.jpg", + original: "image/path/to/image.jpg", + small: "small/path/to/image.jpg", + thumbnail: "thumbnail/path/to/image.jpg" + }, + priority: 1, + productId: "999", + toGrid: 1, + variantId: "874" + }], + metafields: [{ + description: "description", + key: "key", + namespace: "namespace", + scope: "scope", + value: "value", + valueType: "valueType" + }], + minOrderQuantity: 0, + optionTitle: "Awesome Soft Bike", + originCountry: "US", + price: 992, + pricing: { + USD: { + compareAtPrice: null, + displayPrice: "$992.00", + maxPrice: 992, + minPrice: 992, + price: 992 + } + }, + primaryImage: { + URLs: { + large: "large/path/to/image.jpg", + medium: "medium/path/to/image.jpg", + original: "image/path/to/image.jpg", + small: "small/path/to/image.jpg", + thumbnail: "thumbnail/path/to/image.jpg" + }, + priority: 1, + productId: "999", + toGrid: 1, + variantId: "874" + }, + shopId: "123", + sku: "sku", + taxCode: "0000", + taxDescription: "taxDescription", + title: "One pound bag", + updatedAt, + variantId: "874", + weight: 2, + width: 2 + }], + originCountry: "US", + price: 0, + pricing: { + USD: { + compareAtPrice: 1100, + displayPrice: "$992.00", + maxPrice: 992, + minPrice: 992, + price: 0 + } + }, + primaryImage: null, + shopId: "123", + sku: "sku", + taxCode: "0000", + taxDescription: "taxDescription", + title: "Small Concrete Pizza", + updatedAt, + variantId: "875", + weight: 0, + width: 0 + }], + vendor: "vendor", + weight: 15.6, + width: 8.4 +}; + +const mockCatalogItemBefore = { + _id: internalCatalogItemId, + createdAt, + product: mockCatalogProductBefore, + shopId: "123" +}; + +const mockCatalogItemAfter = { + _id: internalCatalogItemId, + createdAt, + product: mockCatalogProductAfter, + shopId: "123" +}; + +test("updates catalog item products' variants and options inventory with proper values", () => { + mockCollections.Products.fetch.mockReturnValueOnce(mockVariants); + const result = convertCatalogItemVariants(mockCatalogItemBefore, mockCollections); + expect(mockCatalogItemAfter).toEqual(result); +}); diff --git a/imports/test-utils/helpers/mockContext.js b/imports/test-utils/helpers/mockContext.js index a29678285e6..729c98eedb0 100644 --- a/imports/test-utils/helpers/mockContext.js +++ b/imports/test-utils/helpers/mockContext.js @@ -54,6 +54,7 @@ const mockContext = { .mockName(`${collectionName}.find`) .mockReturnThis(), findOne: jest.fn().mockName(`${collectionName}.findOne`), + fetch: jest.fn().mockName(`${collectionName}.fetch`), insertOne: jest.fn().mockName(`${collectionName}.insertOne`), insertMany: jest.fn().mockName(`${collectionName}.insertMany`), toArray: jest.fn().mockName(`${collectionName}.toArray`),