feat: medusa-source-shopify loader (#563)

* added statuses to product + unit test for updating status

* add update to product model

* added integration tests

* added integration test to validate that updating status to null results in invalid_data error

* removed comment

* update GET /store/products integration test

* fixed unit test with IdMap

* init plugin

* changed dbehaviour on invalid status input on admin list products

* mprices

* updated migration to add status = published on all existing products + added integration test on GET /admin/products when status null is provided

* merged product status

* init ShopifyService

* made requested changes to migration and GET /store/products

* fixed test

* made requested changes to migration

* push progress on source plugin

* add webhook product/create handler

* fixed normalization of variant weight

* removed weight func

* work on events

* finished product hooks (error on new variant needs to be fixed)

* fixed order status

* create fulfillments

* update fulfillment on cancel

* refactored services, handle returns though medusa, helper methods

* order updates

* removed dist

* update gitignore

* emit cahnges to product

* added redis ignore check to prevent update loops

* fixed product-variant.deleted event

* fix more events

* fix test

* fix: order taxes

* added refund with no items

* fixes to hooks

* fixed handling refunds and returns issued from Shopify

* added unit tests to ShopifyProductService and ShopifyCollectionService

* linting fix

* prepared loader PR

* fix: jsDocs

* fix: pager

* fix: build output and babelrc

* chore: linting

* fix: address type

* fix: migration clean up

* fix: update snapshots with ext_ids

Co-authored-by: Sebastian Rindom <skrindom@gmail.com>
This commit is contained in:
Kasper Fabricius Kristensen
2021-12-08 10:09:21 +01:00
committed by GitHub
co-authored by Sebastian Rindom
parent f2ba4018fc
commit 577bcc23d4
46 changed files with 7857 additions and 61 deletions
@@ -0,0 +1,14 @@
export const ProductCollectionServiceMock = {
withTransaction: function () {
return this
},
create: jest.fn().mockImplementation((data) => {
return Promise.resolve({
id: `col_${data.metadata.sh_id}`,
...data,
})
}),
retrieveByHandle: jest.fn().mockImplementation((handle) => {
return Promise.resolve(undefined)
}),
}
@@ -0,0 +1,32 @@
import { medusaProducts } from "./test-products"
export const ProductServiceMock = {
withTransaction: function () {
return this
},
create: jest.fn().mockImplementation((data) => {
if (data.handle === "ipod-nano") {
return Promise.resolve(medusaProducts.ipod)
}
}),
update: jest.fn().mockImplementation((_id, _update) => {
return Promise.resolve(medusaProducts.ipod)
}),
retrieveByExternalId: jest.fn().mockImplementation((id) => {
if (id === "shopify_ipod") {
return Promise.resolve(medusaProducts.ipod)
}
if (id === "shopify_deleted") {
return Promise.resolve(medusaProducts.ipod)
}
return Promise.resolve(undefined)
}),
retrieve: jest.fn().mockImplementation((_id, _config) => {
if (_id === "prod_ipod") {
return Promise.resolve(medusaProducts.ipod)
}
}),
addOption: jest.fn().mockImplementation((_id, _title) => {
return Promise.resolve()
}),
}
@@ -0,0 +1,17 @@
export const ProductVariantServiceMock = {
withTransaction: function () {
return this
},
create: jest.fn().mockImplementation((_data) => {
return Promise.resolve({})
}),
update: jest.fn().mockImplementation((_data) => {
return Promise.resolve()
}),
delete: jest.fn().mockImplementation((_data) => {
return Promise.resolve()
}),
addOption: jest.fn().mockImplementation((id, title) => {
return Promise.resolve()
}),
}
@@ -0,0 +1,8 @@
export const ShippingProfileServiceMock = {
retrieveGiftCardDefault: jest.fn().mockImplementation((_data) => {
return Promise.resolve({ id: "gift_card_profile" })
}),
retrieveDefault: jest.fn().mockImplementation((_data) => {
return Promise.resolve({ id: "default_shipping_profile" })
}),
}
@@ -0,0 +1,34 @@
import { shopifyProducts } from "./test-products"
export const ShopifyClientServiceMock = {
get: jest.fn().mockImplementation((params) => {
if (params.path === "products/ipod") {
return Promise.resolve(shopifyProducts.ipod)
}
if (params.path === "products/new_ipod") {
return Promise.resolve(shopifyProducts.new_ipod)
}
if (params.path === "products/shopify_ipod") {
return Promise.resolve({
body: {
product: shopifyProducts.ipod_update,
},
})
}
if (params.path === "products/shopify_deleted") {
return Promise.resolve({
body: {
product: {
...shopifyProducts.ipod,
variants: shopifyProducts.ipod.variants.slice(1, -1),
},
},
})
}
}),
list: jest.fn().mockImplementation((path, _headers, _query) => {
if (path === "products") {
return Promise.resolve([shopifyProducts.ipod])
}
}),
}
@@ -0,0 +1,10 @@
import { medusaProducts } from "./test-products"
export const ShopifyProductServiceMock = {
withTransaction: function () {
return this
},
create: jest.fn().mockImplementation((_data) => {
return Promise.resolve(medusaProducts.ipod)
}),
}
@@ -0,0 +1,8 @@
export const ShopifyRedisServiceMock = {
addIgnore: jest.fn().mockImplementation((_id, _event) => {
return Promise.resolve()
}),
shouldIgnore: jest.fn().mockImplementation((_id, _event) => {
return false
}),
}
@@ -0,0 +1,577 @@
export const medusaProducts = {
ipod: {
id: "prod_ipod",
title: "IPod Nano - 8GB",
subtitle: null,
description:
"<p>It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.</p>",
handle: "ipod-nano",
is_giftcard: false,
status: "proposed",
images: [
{
id: "img_01FHZ9ZFC4SP5ME8T18SW47R7S",
url:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
{
id: "img_01FHZ9ZFC4SK54GE072FHMQ5XT",
url:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano-2.png?v=1633120966",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
{
id: "img_01FHZ9ZFC44EKQPVWK76HBZT0S",
url:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
thumbnail:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
options: [
{
id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX3",
title: "Color",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
variants: [
{
id: "variant_01FHZ9ZFED5MEX3V4A7H5V2A0C",
title: "Pink",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
prices: [
{
id: "ma_01FHZ9ZFEPN276FGACR08ZBZ2M",
currency_code: "usd",
amount: 19900,
sale_amount: null,
variant_id: "variant_01FHZ9ZFED5MEX3V4A7H5V2A0C",
region_id: null,
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
},
],
sku: "IPOD2008PINK",
barcode: "1234_pink",
ean: null,
upc: "1234_pink",
inventory_quantity: 10,
allow_backorder: true,
manage_inventory: true,
hs_code: null,
origin_country: null,
mid_code: null,
material: null,
weight: 567,
length: null,
height: null,
width: null,
options: [
{
id: "optval_01FHZ9ZFEDMCJME1BR4X8H89MG",
value: "Pink",
option_id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX3",
variant_id: "variant_01FHZ9ZFED5MEX3V4A7H5V2A0C",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: {
sh_id: 808950810,
},
},
{
id: "variant_01FHZ9ZFFA2KPA4JJSKF07VGWH",
title: "Red",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
prices: [
{
id: "ma_01FHZ9ZFFFX31Z9GZ8ZHQ5KQ2P",
currency_code: "usd",
amount: 19900,
sale_amount: null,
variant_id: "variant_01FHZ9ZFFA2KPA4JJSKF07VGWH",
region_id: null,
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
},
],
sku: "IPOD2008RED",
barcode: "1234_red",
ean: null,
upc: "1234_red",
inventory_quantity: 20,
allow_backorder: true,
manage_inventory: true,
hs_code: null,
origin_country: null,
mid_code: null,
material: null,
weight: 567,
length: null,
height: null,
width: null,
options: [
{
id: "optval_01FHZ9ZFFASNAD8JSWS85Y4ZM0",
value: "Red",
option_id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX3",
variant_id: "variant_01FHZ9ZFFA2KPA4JJSKF07VGWH",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: {
sh_id: 49148385,
},
},
{
id: "variant_01FHZ9ZFG2F54TZMAWV0RJCQ5D",
title: "Green",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
prices: [
{
id: "ma_01FHZ9ZFG6QPPCG4XPX8JG0HGW",
currency_code: "usd",
amount: 19900,
sale_amount: null,
variant_id: "variant_01FHZ9ZFG2F54TZMAWV0RJCQ5D",
region_id: null,
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
},
],
sku: "IPOD2008GREEN",
barcode: "1234_green",
ean: null,
upc: "1234_green",
inventory_quantity: 30,
allow_backorder: true,
manage_inventory: true,
hs_code: null,
origin_country: null,
mid_code: null,
material: null,
weight: 567,
length: null,
height: null,
width: null,
options: [
{
id: "optval_01FHZ9ZFG2K7GG7B79VVZW6AKN",
value: "Green",
option_id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX3",
variant_id: "variant_01FHZ9ZFG2F54TZMAWV0RJCQ5D",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: {
sh_id: 39072856,
},
},
{
id: "variant_01FHZ9ZFGN4GZN3P6JEB39C9G8",
title: "Black",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
prices: [
{
id: "ma_01FHZ9ZFGVNSHF4YRANBJSVN11",
currency_code: "usd",
amount: 19900,
sale_amount: null,
variant_id: "variant_01FHZ9ZFGN4GZN3P6JEB39C9G8",
region_id: null,
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
},
],
sku: "IPOD2008BLACK",
barcode: "1234_black",
ean: null,
upc: "1234_black",
inventory_quantity: 40,
allow_backorder: true,
manage_inventory: true,
hs_code: null,
origin_country: null,
mid_code: null,
material: null,
weight: 567,
length: null,
height: null,
width: null,
options: [
{
id: "optval_01FHZ9ZFGNJXHVHFERQHR1AXHX",
value: "Black",
option_id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX3",
variant_id: "variant_01FHZ9ZFGN4GZN3P6JEB39C9G8",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: {
sh_id: 457924702,
},
},
],
profile_id: "sp_01FHZ9YRZT5DD4HTKJG2T38WXD",
weight: null,
length: null,
height: null,
width: null,
hs_code: null,
origin_country: null,
mid_code: null,
material: null,
collection_id: null,
collection: null,
type_id: null,
type: null,
tags: [
{
id: "ptag_01FHZ9ZFBVN7EK9VZ2EC0T2Y1A",
value: "Emotive",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
{
id: "ptag_01FHZ9ZFBY01226XFYXP0R3EEK",
value: " Flash Memory",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
{
id: "ptag_01FHZ9ZFC0N8S7G85F2TZXKSAM",
value: " MP3",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
{
id: "ptag_01FHZ9ZFC2K6V0A43S398DC2Q2",
value: " Music",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
discountable: true,
external_id: "shopify_ipod",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
}
const shopifyIpod = {
id: "shopify_ipod",
title: "IPod Nano - 8GB",
body_html:
"<p>It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.</p>",
vendor: "Apple",
product_type: "Cult Products",
created_at: "2021-10-01T16:42:46-04:00",
handle: "ipod-nano",
updated_at: "2021-10-01T16:42:46-04:00",
published_at: "2007-12-31T19:00:00-05:00",
template_suffix: null,
status: "active",
published_scope: "web",
tags: "Emotive, Flash Memory, MP3, Music",
admin_graphql_api_id: "gid://shopify/Product/632910392",
variants: [
{
id: 808950810,
product_id: 632910392,
title: "Pink",
price: "199.00",
sku: "IPOD2008PINK",
position: 1,
inventory_policy: "continue",
compare_at_price: null,
fulfillment_service: "manual",
inventory_management: "shopify",
option1: "Pink",
option2: null,
option3: null,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
taxable: true,
barcode: "1234_pink",
grams: 567,
image_id: 562641783,
weight: 1.25,
weight_unit: "lb",
inventory_item_id: 808950810,
inventory_quantity: 10,
old_inventory_quantity: 10,
presentment_prices: [
{
price: {
amount: "199.00",
currency_code: "USD",
},
compare_at_price: null,
},
],
requires_shipping: true,
admin_graphql_api_id: "gid://shopify/ProductVariant/808950810",
},
{
id: 49148385,
product_id: 632910392,
title: "Red",
price: "199.00",
sku: "IPOD2008RED",
position: 2,
inventory_policy: "continue",
compare_at_price: null,
fulfillment_service: "manual",
inventory_management: "shopify",
option1: "Red",
option2: null,
option3: null,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
taxable: true,
barcode: "1234_red",
grams: 567,
image_id: null,
weight: 1.25,
weight_unit: "lb",
inventory_item_id: 49148385,
inventory_quantity: 20,
old_inventory_quantity: 20,
presentment_prices: [
{
price: {
amount: "199.00",
currency_code: "USD",
},
compare_at_price: null,
},
],
requires_shipping: true,
admin_graphql_api_id: "gid://shopify/ProductVariant/49148385",
},
{
id: 39072856,
product_id: 632910392,
title: "Green",
price: "199.00",
sku: "IPOD2008GREEN",
position: 3,
inventory_policy: "continue",
compare_at_price: null,
fulfillment_service: "manual",
inventory_management: "shopify",
option1: "Green",
option2: null,
option3: null,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
taxable: true,
barcode: "1234_green",
grams: 567,
image_id: null,
weight: 1.25,
weight_unit: "lb",
inventory_item_id: 39072856,
inventory_quantity: 30,
old_inventory_quantity: 30,
presentment_prices: [
{
price: {
amount: "199.00",
currency_code: "USD",
},
compare_at_price: null,
},
],
requires_shipping: true,
admin_graphql_api_id: "gid://shopify/ProductVariant/39072856",
},
{
id: 457924702,
product_id: 632910392,
title: "Black",
price: "199.00",
sku: "IPOD2008BLACK",
position: 4,
inventory_policy: "continue",
compare_at_price: null,
fulfillment_service: "manual",
inventory_management: "shopify",
option1: "Black",
option2: null,
option3: null,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
taxable: true,
barcode: "1234_black",
grams: 567,
image_id: null,
weight: 1.25,
weight_unit: "lb",
inventory_item_id: 457924702,
inventory_quantity: 40,
old_inventory_quantity: 40,
presentment_prices: [
{
price: {
amount: "199.00",
currency_code: "USD",
},
compare_at_price: null,
},
],
requires_shipping: true,
admin_graphql_api_id: "gid://shopify/ProductVariant/457924702",
},
],
options: [
{
id: 594680422,
product_id: 632910392,
name: "Color",
position: 1,
values: ["Pink", "Red", "Green", "Black"],
},
],
images: [
{
id: 850703190,
product_id: 632910392,
position: 1,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
alt: null,
width: 123,
height: 456,
src:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
variant_ids: [],
admin_graphql_api_id: "gid://shopify/ProductImage/850703190",
},
{
id: 562641783,
product_id: 632910392,
position: 2,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
alt: null,
width: 123,
height: 456,
src:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano-2.png?v=1633120966",
variant_ids: [808950810],
admin_graphql_api_id: "gid://shopify/ProductImage/562641783",
},
{
id: 378407906,
product_id: 632910392,
position: 3,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
alt: null,
width: 123,
height: 456,
src:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
variant_ids: [],
admin_graphql_api_id: "gid://shopify/ProductImage/378407906",
},
],
image: {
id: 850703190,
product_id: 632910392,
position: 1,
created_at: "2021-10-01T16:42:46-04:00",
updated_at: "2021-10-01T16:42:46-04:00",
alt: null,
width: 123,
height: 456,
src:
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
variant_ids: [],
admin_graphql_api_id: "gid://shopify/ProductImage/850703190",
},
}
export const shopifyProducts = {
ipod: shopifyIpod,
new_ipod: { ...shopifyIpod, id: "shopify_new_ipod" },
ipod_update: {
...shopifyIpod,
title: "IPod Nano",
options: [
...shopifyIpod.options,
{
id: 594680423,
product_id: 632910392,
name: "Memory",
position: 2,
values: ["8GB", "16GB"],
},
],
variants: [
{ ...shopifyIpod.variants[0], title: "Pink / 8GB", option2: "8GB" },
{ ...shopifyIpod.variants[1], title: "Red / 8GB", option2: "8GB" },
{ ...shopifyIpod.variants[2], title: "Green / 8GB", option2: "8GB" },
{ ...shopifyIpod.variants[3], title: "Black / 8GB", option2: "8GB" },
{ ...shopifyIpod.variants[0], title: "Pink / 16GB", option2: "16GB" },
{ ...shopifyIpod.variants[1], title: "Red / 16GB", option2: "16GB" },
{ ...shopifyIpod.variants[2], title: "Green / 16GB", option2: "16GB" },
{ ...shopifyIpod.variants[3], title: "Black / 16GB", option2: "16GB" },
],
},
}
@@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ShopifyCollectionService create normalizes a collection from Shopify 1`] = `
Object {
"handle": "spring",
"metadata": Object {
"sh_body": "spring collection",
"sh_id": "spring",
},
"title": "Spring",
}
`;
@@ -0,0 +1,157 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ShopifyProductService normalizeProduct_ succesfully normalizes a product from Shopify 1`] = `
Object {
"collection_id": null,
"description": "<p>It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.</p>",
"external_id": "shopify_ipod",
"handle": "ipod-nano",
"images": Array [
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano-2.png?v=1633120966",
"https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
],
"is_giftcard": false,
"options": Array [
Object {
"title": "Color",
"values": Array [
Object {
"value": "Pink",
},
Object {
"value": "Red",
},
Object {
"value": "Green",
},
Object {
"value": "Black",
},
],
},
],
"product_type": Object {
"value": "Cult Products",
},
"status": "proposed",
"tags": Array [
Object {
"value": "Emotive",
},
Object {
"value": " Flash Memory",
},
Object {
"value": " MP3",
},
Object {
"value": " Music",
},
],
"thumbnail": "https://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1633120966",
"title": "IPod Nano - 8GB",
"variants": Array [
Object {
"allow_backorder": true,
"barcode": "1234_pink",
"inventory_quantity": 10,
"manage_inventory": true,
"metadata": Object {
"sh_id": 808950810,
},
"options": Array [
Object {
"value": "Pink",
},
],
"prices": Array [
Object {
"amount": 19900,
"currency_code": "usd",
},
],
"sku": "IPOD2008PINK",
"title": "Pink",
"upc": "1234_pink",
"variant_rank": 1,
"weight": 567,
},
Object {
"allow_backorder": true,
"barcode": "1234_red",
"inventory_quantity": 20,
"manage_inventory": true,
"metadata": Object {
"sh_id": 49148385,
},
"options": Array [
Object {
"value": "Red",
},
],
"prices": Array [
Object {
"amount": 19900,
"currency_code": "usd",
},
],
"sku": "IPOD2008RED",
"title": "Red",
"upc": "1234_red",
"variant_rank": 2,
"weight": 567,
},
Object {
"allow_backorder": true,
"barcode": "1234_green",
"inventory_quantity": 30,
"manage_inventory": true,
"metadata": Object {
"sh_id": 39072856,
},
"options": Array [
Object {
"value": "Green",
},
],
"prices": Array [
Object {
"amount": 19900,
"currency_code": "usd",
},
],
"sku": "IPOD2008GREEN",
"title": "Green",
"upc": "1234_green",
"variant_rank": 3,
"weight": 567,
},
Object {
"allow_backorder": true,
"barcode": "1234_black",
"inventory_quantity": 40,
"manage_inventory": true,
"metadata": Object {
"sh_id": 457924702,
},
"options": Array [
Object {
"value": "Black",
},
],
"prices": Array [
Object {
"amount": 19900,
"currency_code": "usd",
},
],
"sku": "IPOD2008BLACK",
"title": "Black",
"upc": "1234_black",
"variant_rank": 4,
"weight": 567,
},
],
}
`;
@@ -0,0 +1,80 @@
import { MockManager } from "medusa-test-utils"
import ShopifyCollectionService from "../shopify-collection"
import { ProductCollectionServiceMock } from "../__mocks__/product-collection"
import { ShopifyProductServiceMock } from "../__mocks__/shopify-product"
import { shopifyProducts } from "../__mocks__/test-products"
describe("ShopifyCollectionService", () => {
describe("create", () => {
const shopifyCollectionService = new ShopifyCollectionService({
manager: MockManager,
shopifyProductService: ShopifyProductServiceMock,
productCollectionService: ProductCollectionServiceMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("creates a collection with a product", async () => {
const collects = [
{
collection_id: "spring",
created_at: "2018-04-25T13:51:12-04:00",
id: 841564295,
position: 2,
product_id: "shopify_ipod",
sort_value: "0000000002",
updated_at: "2018-04-25T13:51:12-04:00",
},
]
const collections = [
{
id: "spring",
body_html: "spring collection",
title: "Spring",
handle: "spring",
},
]
const products = [shopifyProducts.ipod]
const results = await shopifyCollectionService.createWithProducts(
collects,
collections,
products
)
expect(
ProductCollectionServiceMock.retrieveByHandle
).toHaveBeenCalledTimes(1)
expect(ProductCollectionServiceMock.create).toHaveBeenCalledTimes(1)
expect(ShopifyProductServiceMock.create).toHaveBeenCalledTimes(1)
expect(results).toEqual([
{
id: "col_spring",
title: "Spring",
handle: "spring",
metadata: {
sh_id: "spring",
sh_body: "spring collection",
},
},
])
})
it("normalizes a collection from Shopify", () => {
const shopifyCollection = {
id: "spring",
body_html: "spring collection",
title: "Spring",
handle: "spring",
}
const normalized = shopifyCollectionService.normalizeCollection_(
shopifyCollection
)
expect(normalized).toMatchSnapshot()
})
})
})
@@ -0,0 +1,116 @@
import { MockManager } from "medusa-test-utils"
import ShopifyProductService from "../shopify-product"
import { ProductServiceMock } from "../__mocks__/product-service"
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
import { ShippingProfileServiceMock } from "../__mocks__/shipping-profile"
import { ShopifyClientServiceMock } from "../__mocks__/shopify-client"
import { ShopifyRedisServiceMock } from "../__mocks__/shopify-redis"
import { medusaProducts, shopifyProducts } from "../__mocks__/test-products"
describe("ShopifyProductService", () => {
describe("normalizeProduct_", () => {
const shopifyProductService = new ShopifyProductService({
manager: MockManager,
shopifyClientService: ShopifyClientServiceMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("succesfully normalizes a product from Shopify", async () => {
const data = await ShopifyClientServiceMock.get({ path: "products/ipod" })
const normalized = shopifyProductService.normalizeProduct_(data)
expect(normalized).toMatchSnapshot()
})
})
describe("create", () => {
const shopifyProductService = new ShopifyProductService({
manager: MockManager,
shopifyClientService: ShopifyClientServiceMock,
productService: ProductServiceMock,
shopifyRedisService: ShopifyRedisServiceMock,
shippingProfileService: ShippingProfileServiceMock,
productVariantService: ProductVariantServiceMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("succesfully creates a product from Shopify", async () => {
const data = shopifyProducts.new_ipod
const product = await shopifyProductService.create(data)
expect(ShopifyRedisServiceMock.shouldIgnore).toHaveBeenCalledTimes(1)
expect(ShopifyRedisServiceMock.addIgnore).toHaveBeenCalledTimes(1)
expect(ShippingProfileServiceMock.retrieveDefault).toHaveBeenCalledTimes(
1
)
expect(ProductServiceMock.create).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.create).toHaveBeenCalledTimes(4)
expect(product).toEqual(
expect.objectContaining({
id: "prod_ipod",
})
)
})
})
describe("update", () => {
const shopifyProductService = new ShopifyProductService({
manager: MockManager,
shopifyClientService: ShopifyClientServiceMock,
productService: ProductServiceMock,
shopifyRedisService: ShopifyRedisServiceMock,
shippingProfileService: ShippingProfileServiceMock,
productVariantService: ProductVariantServiceMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("updates a product and adds 4 new variants", async () => {
const data = shopifyProducts.ipod_update
jest
.spyOn(shopifyProductService, "addProductOptions_")
.mockImplementation(() => ({
...medusaProducts.ipod,
options: [
...medusaProducts.ipod.options,
{
id: "opt_01FHZ9ZFCPCQ7B1MPDD9X9YQX4",
title: "Memory",
product_id: "prod_01FHZ9ZFC3KKYKA35NXNJ5A7FR",
created_at: "2021-10-14T11:46:10.391Z",
updated_at: "2021-10-14T11:46:10.391Z",
deleted_at: null,
metadata: null,
},
],
}))
await shopifyProductService.update(data)
expect(ProductVariantServiceMock.update).toHaveBeenCalledTimes(8)
expect(ProductServiceMock.update).toHaveBeenCalledTimes(1)
expect(ShopifyClientServiceMock.get).toHaveBeenCalledTimes(1)
})
it("updates a product and deletes 2 existing variants", async () => {
const data = { ...shopifyProducts.ipod, id: "shopify_deleted" }
data.variants = data.variants.slice(1, -1)
await shopifyProductService.update(data)
expect(ProductVariantServiceMock.delete).toHaveBeenCalledTimes(2)
expect(ProductServiceMock.update).toHaveBeenCalledTimes(1)
expect(ShopifyClientServiceMock.get).toHaveBeenCalledTimes(1)
})
})
})
@@ -0,0 +1,42 @@
import { BaseService } from "medusa-interfaces"
import { pager } from "../utils/pager"
import { createClient } from "../utils/create-client"
import { DataType } from "@shopify/shopify-api"
class ShopifyClientService extends BaseService {
// eslint-disable-next-line no-empty-pattern
constructor({}, options) {
super()
this.options = options
/** @private @const {ShopifyRestClient} */
this.client_ = createClient(this.options)
}
get(params) {
return this.client_.get(params)
}
async list(path, extraHeaders = null, extraQuery = {}) {
return await pager(this.client_, path, extraHeaders, extraQuery)
}
delete(params) {
return this.client_.post(params)
}
post(params) {
return this.client_.post({
path: params.path,
body: params.body,
type: DataType.JSON,
})
}
put(params) {
return this.client_.post(params)
}
}
export default ShopifyClientService
@@ -0,0 +1,104 @@
import { BaseService } from "medusa-interfaces"
import { removeIndex } from "../utils/remove-index"
class ShopifyCollectionService extends BaseService {
constructor(
{ manager, shopifyProductService, productCollectionService },
options
) {
super()
this.options = options
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ShopifyProductService} */
this.productService_ = shopifyProductService
/** @private @const {ProductCollectionService} */
this.collectionService_ = productCollectionService
}
withTransaction(transactionManager) {
if (!transactionManager) {
return this
}
const cloned = new ShopifyCollectionService({
manager: transactionManager,
options: this.options,
shopifyProductService: this.productService_,
productCollectionService: this.collectionService_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
*
* @param {Object[]} collects
* @param {Object[]} collections
* @param {Object[]} products
* @return {Promise}
*/
async createWithProducts(collects, collections, products) {
return this.atomicPhase_(async (manager) => {
const normalizedCollections = collections.map((c) =>
this.normalizeCollection_(c)
)
const result = []
for (const nc of normalizedCollections) {
let collection = await this.collectionService_
.retrieveByHandle(nc.handle)
.catch((_) => undefined)
if (!collection) {
collection = await this.collectionService_
.withTransaction(manager)
.create(nc)
}
const productIds = collects.reduce((productIds, c) => {
if (c.collection_id === collection.metadata.sh_id) {
productIds.push(c.product_id)
}
return productIds
}, [])
const reducedProducts = products.reduce((reducedProducts, p) => {
if (productIds.includes(p.id)) {
reducedProducts.push(p)
removeIndex(products, p)
}
return reducedProducts
}, [])
for (const product of reducedProducts) {
await this.productService_
.withTransaction(manager)
.create(product, collection.id)
}
result.push(collection)
}
return result
})
}
normalizeCollection_(shopifyCollection) {
return {
title: shopifyCollection.title,
handle: shopifyCollection.handle,
metadata: {
sh_id: shopifyCollection.id,
sh_body: shopifyCollection.body_html,
},
}
}
}
export default ShopifyCollectionService
@@ -0,0 +1,553 @@
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
import _ from "lodash"
import { parsePrice } from "../utils/parse-price"
import { INCLUDE_PRESENTMENT_PRICES } from "../utils/const"
import axios from "axios"
class ShopifyProductService extends BaseService {
constructor(
{
manager,
productService,
productVariantService,
shippingProfileService,
shopifyClientService,
shopifyRedisService,
},
options
) {
super()
this.options = options
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ProductService} */
this.productService_ = productService
/** @private @const {ProductVariantService} */
this.productVariantService_ = productVariantService
/** @private @const {ShippingProfileService} */
this.shippingProfileService_ = shippingProfileService
/** @private @const {ShopifyRestClient} */
this.shopify_ = shopifyClientService
this.redis_ = shopifyRedisService
}
withTransaction(transactionManager) {
if (!transactionManager) {
return this
}
const cloned = new ShopifyProductService({
manager: transactionManager,
options: this.options,
shippingProfileService: this.shippingProfileService_,
productVariantService: this.productVariantService_,
productService: this.productService_,
shopifyClientService: this.shopify_,
shopifyRedisService: this.redis_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Creates a product based on an event in Shopify.
* Also adds the product to a collection if a collection id is provided
* @param {object} data
* @param {string} collectionId optional
* @return {Product} the created product
*/
async create(data, collectionId) {
return this.atomicPhase_(async (manager) => {
const ignore = await this.redis_.shouldIgnore(data.id, "product.created")
if (ignore) {
return
}
const existingProduct = await this.productService_
.withTransaction(manager)
.retrieveByExternalId(data.id)
.catch((_) => undefined)
if (existingProduct) {
return await this.update(data)
}
const normalizedProduct = this.normalizeProduct_(data, collectionId)
normalizedProduct.profile_id = await this.getShippingProfile_(
normalizedProduct.is_giftcard
)
let variants = normalizedProduct.variants
delete normalizedProduct.variants
const product = await this.productService_
.withTransaction(manager)
.create(normalizedProduct)
if (variants) {
variants = variants.map((v) =>
this.addVariantOptions_(v, product.options)
)
for (const variant of variants) {
await this.productVariantService_
.withTransaction(manager)
.create(product.id, variant)
}
}
await this.redis_.addIgnore(data.id, "product.created")
return product
})
}
async update(data) {
return this.atomicPhase_(async (manager) => {
const ignore = await this.redis_.shouldIgnore(data.id, "product.updated")
if (ignore) {
return
}
let existing = await this.productService_
.retrieveByExternalId(data.id, {
relations: ["variants", "options"],
})
.catch((_) => undefined)
if (!existing) {
return await this.create(data)
}
/**
* Variants received from webhook do not include
* presentment prices. Therefore, we fetch them
* separately, and add to the data object.
*/
const { variants } = await this.shopify_
.get({
path: `products/${data.id}`,
extraHeaders: INCLUDE_PRESENTMENT_PRICES,
})
.then((res) => {
return res.body.product
})
data.variants = variants || []
const normalized = this.normalizeProduct_(data)
existing = await this.addProductOptions_(existing, normalized.options)
await this.updateVariants_(existing, normalized.variants)
await this.deleteVariants_(existing, normalized.variants)
delete normalized.variants
const update = {}
for (const key of Object.keys(normalized)) {
if (normalized[key] !== existing[key]) {
update[key] = normalized[key]
}
}
if (!_.isEmpty(update)) {
await this.redis_.addIgnore(data.id, "product.updated")
return await this.productService_
.withTransaction(manager)
.update(existing.id, update)
}
return Promise.resolve()
})
}
/**
* Deletes a product based on an event in Shopify
* @param {string} id
* @return {Promise}
*/
async delete(id) {
return this.atomicPhase_(async (manager) => {
const product = await this.productService_.retrieveByExternalId(id)
return await this.productService_
.withTransaction(manager)
.delete(product.id)
})
}
async shopifyProductUpdate(id, fields) {
const product = await this.productService_.retrieve(id, {
relations: ["tags", "type"],
})
// Event was not emitted by update
if (!fields) {
return
}
const update = {
product: {
id: product.external_id,
},
}
if (fields.includes("title")) {
update.product.title = product.title
}
if (fields.includes("tags")) {
const values = product.tags.map((t) => t.value)
update.product.tags = values.join(",")
}
if (fields.includes("description")) {
update.product.body_html = product.description
}
if (fields.includes("handle")) {
update.product.handle = product.handle
}
if (fields.includes("type")) {
update.product.type = product.type?.value
}
await axios
.put(
`https://${this.options.domain}.myshopify.com/admin/api/2021-10/products/${product.external_id}.json`,
update,
{
headers: {
"X-Shopify-Access-Token": this.options.password,
},
}
)
.catch((err) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`An error occured while attempting to issue a product update to Shopify: ${err.message}`
)
})
await this.redis_.addIgnore(product.external_id, "product.updated")
}
async shopifyVariantUpdate(id, fields) {
const variant = await this.productVariantService_.retrieve(id, {
relations: ["prices", "product"],
})
// Event was not emitted by update
if (!fields) {
return
}
const update = {
variant: {
id: variant.metadata.sh_id,
},
}
if (fields.includes("title")) {
update.variant.title = variant.title
}
if (fields.includes("allow_backorder")) {
update.variant.inventory_police = variant.allow_backorder
? "continue"
: "deny"
}
if (fields.includes("prices")) {
update.variant.price = variant.prices[0].amount / 100
}
if (fields.includes("weight")) {
update.variant.grams = variant.weight
}
await axios
.put(
`https://${this.options.domain}.myshopify.com/admin/api/2021-10/variants/${variant.metadata.sh_id}.json`,
update,
{
headers: {
"X-Shopify-Access-Token": this.options.password,
},
}
)
.catch((err) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`An error occured while attempting to issue a product update to Shopify: ${err.message}`
)
})
await this.redis_.addIgnore(
variant.metadata.sh_id,
"product-variant.updated"
)
}
async shopifyVariantDelete(productId, metadata) {
const product = await this.productService_.retrieve(productId)
await axios
.delete(
`https://${this.options.domain}.myshopify.com/admin/api/2021-10/products/${product.external_id}/variants/${metadata.sh_id}.json`,
{
headers: {
"X-Shopify-Access-Token": this.options.password,
},
}
)
.catch((err) => {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`An error occured while attempting to issue a product variant delete to Shopify: ${err.message}`
)
})
await this.redis_.addIgnore(metadata.sh_id, "product-variant.deleted")
}
async updateCollectionId(productId, collectionId) {
return this.atomicPhase_(async (manager) => {
return await this.productService_
.withTransaction(manager)
.update(productId, { collection_id: collectionId })
})
}
async updateVariants_(product, updateVariants) {
return this.atomicPhase_(async (manager) => {
const { id, variants, options } = product
for (let variant of updateVariants) {
const ignore =
(await this.redis_.shouldIgnore(
variant.metadata.sh_id,
"product-variant.updated"
)) ||
(await this.redis_.shouldIgnore(
variant.metadata.sh_id,
"product-variant.created"
))
if (ignore) {
continue
}
variant = this.addVariantOptions_(variant, options)
const match = variants.find((v) => v.sku === variant.sku)
if (match) {
await this.productVariantService_
.withTransaction(manager)
.update(match.id, variant)
} else {
await this.productVariantService_
.withTransaction(manager)
.create(id, variant)
}
}
})
}
async deleteVariants_(product, updateVariants) {
return this.atomicPhase_(async (manager) => {
const { variants } = product
for (const variant of variants) {
const ignore = await this.redis_.shouldIgnore(
variant.metadata.sh_id,
"product-variant.deleted"
)
if (ignore) {
continue
}
const match = updateVariants.find((v) => v.sku === variant.sku)
if (!match) {
await this.productVariantService_
.withTransaction(manager)
.delete(variant.id)
}
}
})
}
addVariantOptions_(variant, productOptions) {
const options = productOptions.map((o, i) => ({
option_id: o.id,
...variant.options[i],
}))
variant.options = options
return variant
}
async addProductOptions_(product, updateOptions) {
return this.atomicPhase_(async (manager) => {
const { id, options } = product
for (const option of updateOptions) {
const match = options.find((o) => o.title === option.title)
if (!match) {
await this.productService_
.withTransaction(manager)
.addOption(id, option.title)
}
}
const result = await this.productService_.retrieve(id, {
relations: ["variants", "options"],
})
return result
})
}
async getShippingProfile_(isGiftCard) {
let shippingProfile
if (isGiftCard) {
shippingProfile =
await this.shippingProfileService_.retrieveGiftCardDefault()
} else {
shippingProfile = await this.shippingProfileService_.retrieveDefault()
}
return shippingProfile
}
/**
* Normalizes a product, with a possible optional collection id
* @param {object} product
* @param {string} collectionId optional
* @return {object} normalized object
*/
normalizeProduct_(product, collectionId) {
return {
title: product.title,
handle: product.handle,
description: product.body_html,
product_type: {
value: product.product_type,
},
is_giftcard: product.product_type === "Gift Cards",
options:
product.options.map((option) => this.normalizeProductOption_(option)) ||
[],
variants:
product.variants.map((variant) => this.normalizeVariant_(variant)) ||
[],
tags: product.tags.split(",").map((tag) => this.normalizeTag_(tag)) || [],
images: product.images.map((img) => img.src) || [],
thumbnail: product.image?.src || null,
collection_id: collectionId || null,
external_id: product.id,
status: "proposed",
}
}
/**
* Normalizes a product option
* @param {object} option
* @return {object} normalized ProductOption
*/
normalizeProductOption_(option) {
return {
title: option.name,
values: option.values.map((v) => {
return { value: v }
}),
}
}
/**
* Normalizes a product variant
* @param {object} variant
* @return {object} normalized variant
*/
normalizeVariant_(variant) {
return {
title: variant.title,
prices: this.normalizePrices_(variant.presentment_prices),
sku: variant.sku || null,
barcode: variant.barcode || null,
upc: variant.barcode || null,
inventory_quantity: variant.inventory_quantity,
variant_rank: variant.position,
allow_backorder: variant.inventory_policy === "continue",
manage_inventory: variant.inventory_management === "shopify",
weight: variant.grams,
options: this.normalizeVariantOptions_(
variant.option1,
variant.option2,
variant.option3
),
metadata: {
sh_id: variant.id,
},
}
}
/**
* Normalizes an array of presentment prices
* @param {array} presentmentPrices
* @return {Object[]} array of normalized prices
*/
normalizePrices_(presentmentPrices) {
return presentmentPrices.map((p) => {
return {
amount: parsePrice(p.price.amount),
currency_code: p.price.currency_code.toLowerCase(),
}
})
}
/**
* Normalizes the three possble variant options
* @param {string} option1
* @param {string} option2
* @param {string} option3
* @return {Object[]} normalized variant options
*/
normalizeVariantOptions_(option1, option2, option3) {
const opts = []
if (option1) {
opts.push({
value: option1,
})
}
if (option2) {
opts.push({
value: option2,
})
}
if (option3) {
opts.push({
value: option3,
})
}
return opts
}
/**
* Normalizes a tag
* @param {string} tag
* @return {Object} normalized Tag
*/
normalizeTag_(tag) {
return {
value: tag,
}
}
}
export default ShopifyProductService
@@ -0,0 +1,30 @@
import { BaseService } from "medusa-interfaces"
import { IGNORE_THRESHOLD } from "../utils/const"
class shopifyRedisService extends BaseService {
constructor({ redisClient }, options) {
super()
this.options_ = options
/** @private @const {RedisClient} */
this.redis_ = redisClient
}
async addIgnore(id, side) {
const key = `${id}_ignore_${side}`
return await this.redis_.set(
key,
1,
"EX",
this.options_.ignore_threshold || IGNORE_THRESHOLD
)
}
async shouldIgnore(id, action) {
const key = `${id}_ignore_${action}`
return await this.redis_.get(key)
}
}
export default shopifyRedisService
@@ -0,0 +1,78 @@
import { BaseService } from "medusa-interfaces"
import { INCLUDE_PRESENTMENT_PRICES } from "../utils/const"
class ShopifyService extends BaseService {
constructor(
{
manager,
shippingProfileService,
shopifyProductService,
shopifyCollectionService,
shopifyClientService,
},
options
) {
super()
this.options = options
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ShippingProfileService} */
this.shippingProfileService_ = shippingProfileService
/** @private @const {ShopifyProductService} */
this.productService_ = shopifyProductService
/** @private @const {ShopifyCollectionService} */
this.collectionService_ = shopifyCollectionService
/** @private @const {ShopifyRestClient} */
this.client_ = shopifyClientService
}
withTransaction(transactionManager) {
if (!transactionManager) {
return this
}
const cloned = new ShopifyService({
manager: transactionManager,
options: this.options,
shippingProfileService: this.shippingProfileService_,
shopifyClientService: this.client_,
shopifyProductService: this.productService_,
shopifyCollectionService: this.collectionService_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
async importShopify() {
return this.atomicPhase_(async (manager) => {
await this.shippingProfileService_.createDefault()
await this.shippingProfileService_.createGiftCardDefault()
const products = await this.client_.list(
"products",
INCLUDE_PRESENTMENT_PRICES
)
const customCollections = await this.client_.list("custom_collections")
const smartCollections = await this.client_.list("smart_collections")
const collects = await this.client_.list("collects")
await this.collectionService_
.withTransaction(manager)
.createWithProducts(
collects,
[...customCollections, ...smartCollections],
products
)
for (const product of products) {
await this.productService_.withTransaction(manager).create(product)
}
})
}
}
export default ShopifyService