diff --git a/packages/medusa/src/models/__mocks__/product-variant.js b/packages/medusa/src/models/__mocks__/product-variant.js new file mode 100644 index 0000000000..20f2722907 --- /dev/null +++ b/packages/medusa/src/models/__mocks__/product-variant.js @@ -0,0 +1,33 @@ +import IdMap from "../../helpers/id-map" + +export const ProductVariantModelMock = { + create: jest.fn().mockReturnValue(Promise.resolve()), + updateOne: jest.fn().mockImplementation((query, update) => { + return Promise.resolve() + }), + deleteOne: jest.fn().mockReturnValue(Promise.resolve()), + findOne: jest.fn().mockImplementation(query => { + if (query._id === IdMap.getId("validId")) { + return Promise.resolve({ + _id: IdMap.getId("validId"), + title: "test", + }) + } + if (query._id === IdMap.getId("testVariant")) { + return Promise.resolve({ + _id: IdMap.getId("testVariant"), + title: "test", + }) + } + if (query._id === IdMap.getId("deleteId")) { + return Promise.resolve({ + _id: IdMap.getId("deleteId"), + title: "test", + }) + } + if (query._id === IdMap.getId("failId")) { + return Promise.reject(new Error("test error")) + } + return Promise.resolve(undefined) + }), +} diff --git a/packages/medusa/src/services/__tests__/mocks/product-model.js b/packages/medusa/src/models/__mocks__/product.js similarity index 98% rename from packages/medusa/src/services/__tests__/mocks/product-model.js rename to packages/medusa/src/models/__mocks__/product.js index d69d6f8f2f..42b08f85b4 100644 --- a/packages/medusa/src/services/__tests__/mocks/product-model.js +++ b/packages/medusa/src/models/__mocks__/product.js @@ -1,4 +1,4 @@ -import IdMap from "../../../helpers/id-map" +import IdMap from "../../helpers/id-map" export const ProductModelMock = { create: jest.fn().mockReturnValue(Promise.resolve()), diff --git a/packages/medusa/src/models/product-variant.js b/packages/medusa/src/models/product-variant.js index 3ed959b45b..5a1f1cfe28 100644 --- a/packages/medusa/src/models/product-variant.js +++ b/packages/medusa/src/models/product-variant.js @@ -15,6 +15,8 @@ class ProductVariantModel extends BaseModel { prices: { type: [MoneyAmountSchema], default: [], required: true }, options: { type: [OptionValueSchema], default: [] }, image: { type: String, default: "" }, + metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, + published: { type: Boolean, default: false }, } } diff --git a/packages/medusa/src/models/schemas/option.js b/packages/medusa/src/models/schemas/option.js index 4ebd987362..2cdce004fa 100644 --- a/packages/medusa/src/models/schemas/option.js +++ b/packages/medusa/src/models/schemas/option.js @@ -5,7 +5,6 @@ import mongoose from "mongoose" export default new mongoose.Schema({ - product_id: { type: mongoose.Types.ObjectId, required: true }, title: { type: String, required: true }, values: { type: [String], default: [] }, }) diff --git a/packages/medusa/src/services/__mocks__/product-variant.js b/packages/medusa/src/services/__mocks__/product-variant.js index 233bd28026..a5e5dcfa7b 100644 --- a/packages/medusa/src/services/__mocks__/product-variant.js +++ b/packages/medusa/src/services/__mocks__/product-variant.js @@ -60,6 +60,21 @@ const variant4 = { ], } +const variant5 = { + _id: "5", + title: "Variant with valid id", + options: [ + { + option_id: IdMap.getId("color_id"), + value: "blue", + }, + { + option_id: IdMap.getId("size_id"), + value: "50", + }, + ], +} + const invalidVariant = { _id: "invalid_option", title: "variant3", @@ -104,6 +119,9 @@ export const ProductVariantServiceMock = { if (variantId === "4") { return Promise.resolve(variant4) } + if (variantId === IdMap.getId("validId")) { + return Promise.resolve(variant5) + } if (variantId === "invalid_option") { return Promise.resolve(invalidVariant) } diff --git a/packages/medusa/src/services/__mocks__/product.js b/packages/medusa/src/services/__mocks__/product.js index 74c79606ef..19e8763fc0 100644 --- a/packages/medusa/src/services/__mocks__/product.js +++ b/packages/medusa/src/services/__mocks__/product.js @@ -1,7 +1,26 @@ +import IdMap from "../../helpers/id-map" + export const ProductServiceMock = { createDraft: jest.fn().mockImplementation(data => { return Promise.resolve(data) }), + list: jest.fn().mockImplementation(data => { + if (data.variants === IdMap.getId("testVariant")) { + return Promise.resolve([ + { + _id: "1234", + title: "test", + options: [ + { + _id: IdMap.getId("testOptionId"), + title: "testOption", + }, + ], + }, + ]) + } + return Promise.resolve([]) + }), } const mock = jest.fn().mockImplementation(() => { diff --git a/packages/medusa/src/services/__tests__/mocks/product-variant-service.js b/packages/medusa/src/services/__tests__/mocks/product-variant-service.js deleted file mode 100644 index 4dede4b2ff..0000000000 --- a/packages/medusa/src/services/__tests__/mocks/product-variant-service.js +++ /dev/null @@ -1,122 +0,0 @@ -import IdMap from "../../../helpers/id-map" - -const variant1 = { - _id: "1", - title: "variant1", - options: [ - { - option_id: IdMap.getId("color_id"), - value: "blue", - }, - { - option_id: IdMap.getId("size_id"), - value: "160", - }, - ], -} - -const variant2 = { - _id: "2", - title: "variant2", - options: [ - { - option_id: IdMap.getId("color_id"), - value: "black", - }, - { - option_id: IdMap.getId("size_id"), - value: "160", - }, - ], -} - -const variant3 = { - _id: "3", - title: "variant3", - options: [ - { - option_id: IdMap.getId("color_id"), - value: "blue", - }, - { - option_id: IdMap.getId("size_id"), - value: "150", - }, - ], -} - -const variant4 = { - _id: "4", - title: "variant4", - options: [ - { - option_id: IdMap.getId("color_id"), - value: "blue", - }, - { - option_id: IdMap.getId("size_id"), - value: "50", - }, - ], -} - -const invalidVariant = { - _id: "invalid_option", - title: "variant3", - options: [ - { - option_id: "invalid_id", - value: "blue", - }, - { - option_id: IdMap.getId("size_id"), - value: "150", - }, - ], -} - -const emptyVariant = { - _id: "empty_option", - title: "variant3", - options: [], -} - -export const variants = { - one: variant1, - two: variant2, - three: variant3, - four: variant4, - invalid_variant: invalidVariant, - empty_variant: emptyVariant, -} - -export const ProductVariantServiceMock = { - retrieve: jest.fn().mockImplementation(variantId => { - if (variantId === "1") { - return Promise.resolve(variant1) - } - if (variantId === "2") { - return Promise.resolve(variant2) - } - if (variantId === "3") { - return Promise.resolve(variant3) - } - if (variantId === "4") { - return Promise.resolve(variant4) - } - if (variantId === "invalid_option") { - return Promise.resolve(invalidVariant) - } - if (variantId === "empty_option") { - return Promise.resolve(emptyVariant) - } - return Promise.resolve(undefined) - }), - delete: jest.fn().mockReturnValue(Promise.resolve()), - addOptionValue: jest.fn().mockImplementation((variantId, optionId, value) => { - return Promise.resolve({}) - }), - deleteOptionValue: jest.fn().mockImplementation((variantId, optionId) => { - return Promise.resolve({}) - }), -} diff --git a/packages/medusa/src/services/__tests__/product-variant.js b/packages/medusa/src/services/__tests__/product-variant.js new file mode 100644 index 0000000000..7e31a35398 --- /dev/null +++ b/packages/medusa/src/services/__tests__/product-variant.js @@ -0,0 +1,416 @@ +import mongoose from "mongoose" +import ProductVariantService from "../product-variant" +import { ProductVariantModelMock } from "../../models/__mocks__/product-variant" +import IdMap from "../../helpers/id-map" +import { ProductServiceMock } from "../__mocks__/product" + +describe("ProductVariantService", () => { + describe("retrieve", () => { + describe("successfully get product variant", () => { + let res + beforeAll(async () => { + const productVariantService = new ProductVariantService({ + productVariantModel: ProductVariantModelMock, + }) + + res = await productVariantService.retrieve(IdMap.getId("validId")) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls model layer findOne", () => { + expect(ProductVariantModelMock.findOne).toHaveBeenCalledTimes(1) + expect(ProductVariantModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("validId"), + }) + }) + + it("returns correct variant", () => { + expect(res.title).toEqual("test") + }) + }) + + describe("query fail", () => { + let res + beforeAll(async () => { + const productVariantService = new ProductVariantService({ + productVariantModel: ProductVariantModelMock, + }) + + await productVariantService + .retrieve(IdMap.getId("failId")) + .catch(err => { + res = err + }) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls model layer findOne", () => { + expect(ProductVariantModelMock.findOne).toHaveBeenCalledTimes(1) + expect(ProductVariantModelMock.findOne).toHaveBeenCalledWith({ + _id: IdMap.getId("failId"), + }) + }) + + it("model query throws error", () => { + expect(res.name).toEqual("database_error") + expect(res.message).toEqual("test error") + }) + }) + }) + describe("createDraft", () => { + beforeAll(() => { + jest.clearAllMocks() + const productVariantService = new ProductVariantService({ + productVariantModel: ProductVariantModelMock, + }) + + productVariantService.createDraft({ + title: "Test Prod", + image: "test-image", + options: [], + prices: [ + { + currency_code: "usd", + amount: 100, + }, + ], + }) + }) + + it("calls model layer create", () => { + expect(ProductVariantModelMock.create).toHaveBeenCalledTimes(1) + expect(ProductVariantModelMock.create).toHaveBeenCalledWith({ + title: "Test Prod", + image: "test-image", + options: [], + prices: [ + { + currency_code: "usd", + amount: 100, + }, + ], + published: false, + }) + }) + }) + + describe("publishVariant", () => { + beforeAll(() => { + jest.clearAllMocks() + const productVariantService = new ProductVariantService({ + productVariantModel: ProductVariantModelMock, + }) + + productVariantService.publish(IdMap.getId("variantId")) + }) + + it("calls model layer create", () => { + expect(ProductVariantModelMock.create).toHaveBeenCalledTimes(0) + expect(ProductVariantModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(ProductVariantModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("variantId") }, + { $set: { published: true } } + ) + }) + }) + + describe("update", () => { + const productVariantService = new ProductVariantService({ + productVariantModel: ProductVariantModelMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls updateOne with correct params", async () => { + const id = mongoose.Types.ObjectId() + + await productVariantService.update(`${id}`, { title: "new title" }) + + expect(ProductVariantModelMock.updateOne).toBeCalledTimes(1) + expect(ProductVariantModelMock.updateOne).toBeCalledWith( + { _id: `${id}` }, + { $set: { title: "new title" } }, + { runValidators: true } + ) + }) + + it("throw error on invalid variant id type", async () => { + try { + await productVariantService.update(19314235, { title: "new title" }) + } catch (err) { + expect(err.message).toEqual( + "The variantId could not be casted to an ObjectId" + ) + } + }) + + it("throws error when trying to update metadata", async () => { + const id = mongoose.Types.ObjectId() + try { + await productVariantService.update(`${id}`, { + metadata: { key: "value" }, + }) + } catch (err) { + expect(err.message).toEqual("Use setMetadata to update metadata fields") + } + }) + }) + + describe("decorate", () => { + const productVariantService = new ProductVariantService({ + productVariantModel: ProductVariantModelMock, + }) + + const fakeVariant = { + _id: "1234", + title: "test", + image: "test-image", + prices: [ + { + currency_code: "usd", + amount: 100, + }, + ], + metadata: { testKey: "testValue" }, + published: true, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("returns decorated product", async () => { + const decorated = await productVariantService.decorate(fakeVariant, []) + expect(decorated).toEqual({ + _id: "1234", + metadata: { testKey: "testValue" }, + }) + }) + + it("returns decorated product with handle", async () => { + const decorated = await productVariantService.decorate(fakeVariant, [ + "prices", + ]) + expect(decorated).toEqual({ + _id: "1234", + metadata: { testKey: "testValue" }, + prices: [ + { + currency_code: "usd", + amount: 100, + }, + ], + }) + }) + }) + + describe("setMetadata", () => { + const productVariantService = new ProductVariantService({ + productVariantModel: ProductVariantModelMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calls updateOne with correct params", async () => { + const id = mongoose.Types.ObjectId() + await productVariantService.setMetadata( + `${id}`, + "metadata", + "testMetadata" + ) + + expect(ProductVariantModelMock.updateOne).toBeCalledTimes(1) + expect(ProductVariantModelMock.updateOne).toBeCalledWith( + { _id: `${id}` }, + { $set: { "metadata.metadata": "testMetadata" } } + ) + }) + + it("throw error on invalid key type", async () => { + const id = mongoose.Types.ObjectId() + + try { + await productVariantService.setMetadata(`${id}`, 1234, "nono") + } catch (err) { + expect(err.message).toEqual( + "Key type is invalid. Metadata keys must be strings" + ) + } + }) + + it("throws error on invalid variantId type", async () => { + try { + await productVariantService.setMetadata("fakeVariantId", 1234, "nono") + } catch (err) { + expect(err.message).toEqual( + "The variantId could not be casted to an ObjectId" + ) + } + }) + }) + + describe("addOptionValue", () => { + const productVariantService = new ProductVariantService({ + productVariantModel: ProductVariantModelMock, + productService: ProductServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("it successfully adds option value", async () => { + await productVariantService.addOptionValue( + IdMap.getId("testVariant"), + IdMap.getId("testOptionId"), + "testValue" + ) + + expect(ProductVariantModelMock.updateOne).toBeCalledTimes(1) + expect(ProductVariantModelMock.updateOne).toBeCalledWith( + { _id: IdMap.getId("testVariant") }, + { + $push: { + options: { + option_id: IdMap.getId("testOptionId"), + value: "testValue", + }, + }, + } + ) + }) + + it("it successfully casts numeric option value to string", async () => { + await productVariantService.addOptionValue( + IdMap.getId("testVariant"), + IdMap.getId("testOptionId"), + 1234 + ) + + expect(ProductVariantModelMock.updateOne).toBeCalledTimes(1) + expect(ProductVariantModelMock.updateOne).toBeCalledWith( + { _id: IdMap.getId("testVariant") }, + { + $push: { + options: { + option_id: IdMap.getId("testOptionId"), + value: "1234", + }, + }, + } + ) + }) + + it("throw error if product with variant does not exist", async () => { + try { + await productVariantService.addOptionValue( + IdMap.getId("failId"), + IdMap.getId("testOptionId"), + "testValue" + ) + } catch (err) { + expect(err.message).toEqual( + `Products with variant: ${IdMap.getId("failId")} was not found` + ) + } + }) + + it("throw error if product does not have option id", async () => { + try { + await productVariantService.addOptionValue( + IdMap.getId("testVariant"), + IdMap.getId("failOptionId"), + "testValue" + ) + } catch (err) { + expect(err.message).toEqual( + `Associated product does not have option: ${IdMap.getId( + "failOptionId" + )}` + ) + } + }) + + it("throw error if option value is not string", async () => { + try { + await productVariantService.addOptionValue( + IdMap.getId("testVariant"), + IdMap.getId("testOptionId"), + {} + ) + } catch (err) { + expect(err.message).toEqual( + `Option value is not of type string or number` + ) + } + }) + }) + + describe("deleteOptionValue", () => { + const productVariantService = new ProductVariantService({ + productVariantModel: ProductVariantModelMock, + productService: ProductServiceMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("successfully deletes option value from variant", async () => { + await productVariantService.deleteOptionValue( + IdMap.getId("testVariant"), + IdMap.getId("testing") + ) + + expect(ProductVariantModelMock.updateOne).toBeCalledTimes(1) + expect(ProductVariantModelMock.updateOne).toBeCalledWith( + { _id: IdMap.getId("testVariant") }, + { $pull: { options: { option_id: IdMap.getId("testing") } } } + ) + }) + + it("throw error if product still has the option id of the option value we are trying to delete", async () => { + try { + await productVariantService.deleteOptionValue( + IdMap.getId("testVariant"), + IdMap.getId("testOptionId") + ) + } catch (err) { + expect(err.message).toEqual( + `Associated product has option with id: ${IdMap.getId( + "testOptionId" + )}` + ) + } + }) + }) + + describe("delete", () => { + const productVariantService = new ProductVariantService({ + productVariantModel: ProductVariantModelMock, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("deletes all variants and product successfully", async () => { + await productVariantService.delete(IdMap.getId("deleteId")) + + expect(ProductVariantModelMock.deleteOne).toBeCalledTimes(1) + expect(ProductVariantModelMock.deleteOne).toBeCalledWith({ + _id: IdMap.getId("deleteId"), + }) + }) + }) +}) diff --git a/packages/medusa/src/services/__tests__/product.js b/packages/medusa/src/services/__tests__/product.js index bac638ae8f..30050f4507 100644 --- a/packages/medusa/src/services/__tests__/product.js +++ b/packages/medusa/src/services/__tests__/product.js @@ -1,10 +1,10 @@ import mongoose from "mongoose" import ProductService from "../product" -import { ProductModelMock } from "./mocks/product-model" +import { ProductModelMock } from "../../models/__mocks__/product" import { ProductVariantServiceMock, variants, -} from "./mocks/product-variant-service" +} from "../__mocks__/product-variant" import IdMap from "../../helpers/id-map" describe("ProductService", () => { @@ -107,6 +107,7 @@ describe("ProductService", () => { jest.clearAllMocks() const productService = new ProductService({ productModel: ProductModelMock, + productVariantService: ProductVariantServiceMock, }) productService.publish(IdMap.getId("productId")) @@ -189,7 +190,7 @@ describe("ProductService", () => { }) }) - describe("add metadata to product model", () => { + describe("setMetadata", () => { const productService = new ProductService({ productModel: ProductModelMock, }) @@ -232,7 +233,7 @@ describe("ProductService", () => { }) }) - describe("update product", () => { + describe("update", () => { const productService = new ProductService({ productModel: ProductModelMock, }) @@ -285,7 +286,7 @@ describe("ProductService", () => { }) }) - describe("delete product", () => { + describe("delete", () => { const productService = new ProductService({ productModel: ProductModelMock, productVariantService: ProductVariantServiceMock, @@ -307,16 +308,6 @@ describe("ProductService", () => { _id: IdMap.getId("deleteId"), }) }) - - it("throw error on invalid product id type", async () => { - try { - await productService.update(19314235, { title: "new title" }) - } catch (err) { - expect(err.message).toEqual( - "The productId could not be casted to an ObjectId" - ) - } - }) }) describe("addVariant", () => { diff --git a/packages/medusa/src/services/product-variant.js b/packages/medusa/src/services/product-variant.js index 103961f389..a34c5d57a3 100644 --- a/packages/medusa/src/services/product-variant.js +++ b/packages/medusa/src/services/product-variant.js @@ -1,12 +1,14 @@ +import _ from "lodash" import { BaseService } from "../interfaces" +import { Validator, MedusaError } from "medusa-core-utils" /** - * Provides layer to manipulate products. + * Provides layer to manipulate product variants. * @implements BaseService */ class ProductVariantService extends BaseService { - /** @param { productModel: (ProductModel) } */ - constructor({ productVariantModel, eventBusService }) { + /** @param { productVariantModel: (ProductVariantModel) } */ + constructor({ productVariantModel, eventBusService, productService }) { super() /** @private @const {ProductVariantModel} */ @@ -14,36 +16,250 @@ class ProductVariantService extends BaseService { /** @private @const {EventBus} */ this.eventBus_ = eventBusService + + /** @private @const {ProductService} */ + this.productService_ = productService } /** - * Creates an unpublished product. - * @param {object} product - the product to create + * Used to validate product ids. Throws an error if the cast fails + * @param {string} rawId - the raw product id to validate. + * @return {string} the validated id + */ + validateId_(rawId) { + const schema = Validator.objectId() + const { value, error } = schema.validate(rawId) + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "The variantId could not be casted to an ObjectId" + ) + } + + return value + } + + /** + * Gets a product variant by id. + * @param {string} variantId - the id of the product to get. + * @return {Promise} the product document. + */ + retrieve(variantId) { + const validatedId = this.validateId_(variantId) + return this.productVariantModel_ + .findOne({ _id: validatedId }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Creates an unpublished product variant. + * @param {object} variant - the variant to create * @return {Promise} resolves to the creation result. */ createDraft(productVariant) { - return this.productVariantModel_.create({ - ...productVariant, - published: false, - }) + return this.productVariantModel_ + .create({ + ...productVariant, + published: false, + }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) } /** - * Creates an publishes product. - * @param {string} productId - ID of the product to publish. + * Creates an publishes variant. + * @param {string} variantId - ID of the variant to publish. * @return {Promise} resolves to the creation result. */ publish(variantId) { + return this.productVariantModel_ + .updateOne({ _id: variantId }, { $set: { published: true } }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Updates a variant. Metadata updates and price updates should + * use dedicated methods, e.g. `setMetadata`, etc. The function + * will throw errors if metadata updates and price updates are attempted. + * @param {string} variantId - the id of the variant. Must be a string that + * can be casted to an ObjectId + * @param {object} update - an object with the update values. + * @return {Promise} resolves to the update result. + */ + update(variantId, update) { + const validatedId = this.validateId_(variantId) + + if (update.metadata) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Use setMetadata to update metadata fields" + ) + } + + return this.productVariantModel_ + .updateOne( + { _id: validatedId }, + { $set: update }, + { runValidators: true } + ) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Adds option value to a varaint. + * Fails when product with variant does not exists or + * if that product does not have an option with the given + * option id. Fails if given variant is not found. + * Option value must be of type string or number. + * @param {string} variantId - the variant to decorate. + * @param {string} optionId - the option from product. + * @param {string | number} optionValue - option value to add. + * @return {Promise} the result of the update operation. + */ + async addOptionValue(variantId, optionId, optionValue) { + const products = await this.productService_.list({ variants: variantId }) + if (!products.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Products with variant: ${variantId} was not found` + ) + } + + const product = products[0] + if (!product.options.find(o => o._id === optionId)) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Associated product does not have option: ${optionId}` + ) + } + + const variant = await this.retrieve(variantId) + if (!variant) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Variant with ${variantId} was not found` + ) + } + + if (typeof optionValue !== "string" && typeof optionValue !== "number") { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Option value is not of type string or number` + ) + } + return this.productVariantModel_.updateOne( { _id: variantId }, - { $set: { published: true } } + { $push: { options: { option_id: optionId, value: `${optionValue}` } } } ) } /** - * + * Deletes option value from given variant. + * Fails when product with variant does not exists or + * if that product has an option with the given + * option id. + * This method should only be used from the product service. + * @param {string} variantId - the variant to decorate. + * @param {string} optionId - the option from product. + * @return {Promise} the result of the update operation. */ - addOptionValue(variantId, optionId, optionValue) { + async deleteOptionValue(variantId, optionId) { + const products = await this.productService_.list({ variants: variantId }) + if (!products.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Products with variant: ${variantId} was not found` + ) + } + + const product = products[0] + if (product.options.find(o => o._id === optionId)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Associated product has option with id: ${optionId}` + ) + } + + return this.productVariantModel_.updateOne( + { _id: variantId }, + { $pull: { options: { option_id: optionId } } } + ) + } + + /** + * @param {Object} selector - the query object for find + * @return {Promise} the result of the find operation + */ + list(selector) { + return this.productVariantModel_.find(selector) + } + + /** + * Deletes a variant from given variant id. + * @param {string} variantId - the id of the variant to delete. Must be + * castable as an ObjectId + * @return {Promise} the result of the delete operation. + */ + async delete(variantId) { + const variant = await this.retrieve(variantId) + // Delete is idempotent, but we return a promise to allow then-chaining + if (!variant) { + return Promise.resolve() + } + + return this.productVariantModel_ + .deleteOne({ _id: variantId }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + /** + * Decorates a variant with variant variants. + * @param {ProductVariant} variant - the variant to decorate. + * @param {string[]} fields - the fields to include. + * @param {string[]} expandFields - fields to expand. + * @return {ProductVariant} return the decorated variant. + */ + async decorate(variant, fields, expandFields = []) { + const requiredFields = ["_id", "metadata"] + const decorated = _.pick(variant, fields.concat(requiredFields)) + return decorated + } + + /** + * Dedicated method to set metadata for a variant. + * To ensure that plugins does not overwrite each + * others metadata fields, setMetadata is provided. + * @param {string} variantId - the variant to decorate. + * @param {string} key - key for metadata field + * @param {string} value - value for metadata field. + * @return {Promise} resolves to the updated result. + */ + setMetadata(variantId, key, value) { + const validatedId = this.validateId_(variantId) + + if (typeof key !== "string") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Key type is invalid. Metadata keys must be strings" + ) + } + + const keyPath = `metadata.${key}` + return this.productVariantModel_ + .updateOne({ _id: validatedId }, { $set: { [keyPath]: value } }) + .catch(err => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) } } diff --git a/packages/medusa/src/services/product.js b/packages/medusa/src/services/product.js index 9ec28da466..e5ec539b3b 100644 --- a/packages/medusa/src/services/product.js +++ b/packages/medusa/src/services/product.js @@ -41,10 +41,10 @@ class ProductService extends BaseService { } /** - * + * @param {Object} selector - the query object for find + * @return {Promise} the result of the find operation */ - list(query) { - const selector = {} + list(selector) { return this.productModel_.find(selector) } @@ -477,15 +477,9 @@ class ProductService extends BaseService { `To delete an option, first delete all variants, such that when option is deleted, no duplicate variants will exist. For more info check MEDUSA.com` ) } - - await Promise.all( - product.variants.map(async variantId => - this.productVariantService_.deleteOptionValue(variantId, optionId) - ) - ) } - return this.productModel_.updateOne( + const result = await this.productModel_.updateOne( { _id: productId }, { $pull: { @@ -495,6 +489,17 @@ class ProductService extends BaseService { }, } ) + + // If we reached this point, we can delete option value from variants + if (product.variants) { + await Promise.all( + product.variants.map(async variantId => + this.productVariantService_.deleteOptionValue(variantId, optionId) + ) + ) + } + + return result } /** @@ -543,7 +548,9 @@ class ProductService extends BaseService { } /** - * Sets metadata for a product + * Dedicated method to set metadata for a product. + * To ensure that plugins does not overwrite each + * others metadata fields, setMetadata is provided. * @param {string} productId - the product to decorate. * @param {string} key - key for metadata field * @param {string} value - value for metadata field. diff --git a/packages/medusa/yarn-error.log b/packages/medusa/yarn-error.log index dc268d31bf..b6a998a3db 100644 --- a/packages/medusa/yarn-error.log +++ b/packages/medusa/yarn-error.log @@ -1,29 +1,29 @@ Arguments: - /Users/srindom/.nvm/versions/node/v10.15.3/bin/node /usr/local/Cellar/yarn/1.19.0/libexec/bin/yarn.js add -D client-sessions + /usr/local/bin/node /usr/local/Cellar/yarn/1.17.3/libexec/bin/yarn.js PATH: - /Users/srindom/.rvm/gems/ruby-2.6.3/bin:/Users/srindom/.rvm/gems/ruby-2.6.3@global/bin:/Users/srindom/.rvm/rubies/ruby-2.6.3/bin:/Users/srindom/.nvm/versions/node/v10.15.3/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/TeX/texbin:/Users/srindom/.rvm/bin + /Users/oliverjuhl/Desktop/development/google-cloud-sdk/bin:/Library/Frameworks/Python.framework/Versions/3.8/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/share/dotnet:~/.dotnet/tools:/Library/Frameworks/Mono.framework/Versions/Current/Commands:/Users/oliverjuhl/miniconda3/bin:/Users/oliverjuhl/development/flutter/bin Yarn version: - 1.19.0 + 1.17.3 Node version: - 10.15.3 + 10.15.0 Platform: darwin x64 Trace: Error: https://registry.yarnpkg.com/medusa-core-utils: Not found - at Request.params.callback [as _callback] (/usr/local/Cellar/yarn/1.19.0/libexec/lib/cli.js:66918:18) - at Request.self.callback (/usr/local/Cellar/yarn/1.19.0/libexec/lib/cli.js:140539:22) - at Request.emit (events.js:189:13) - at Request. (/usr/local/Cellar/yarn/1.19.0/libexec/lib/cli.js:141511:10) - at Request.emit (events.js:189:13) - at IncomingMessage. (/usr/local/Cellar/yarn/1.19.0/libexec/lib/cli.js:141433:12) - at Object.onceWrapper (events.js:277:13) - at IncomingMessage.emit (events.js:194:15) - at endReadableNT (_stream_readable.js:1125:12) + at Request.params.callback [as _callback] (/usr/local/Cellar/yarn/1.17.3/libexec/lib/cli.js:66830:18) + at Request.self.callback (/usr/local/Cellar/yarn/1.17.3/libexec/lib/cli.js:140464:22) + at Request.emit (events.js:182:13) + at Request. (/usr/local/Cellar/yarn/1.17.3/libexec/lib/cli.js:141436:10) + at Request.emit (events.js:182:13) + at IncomingMessage. (/usr/local/Cellar/yarn/1.17.3/libexec/lib/cli.js:141358:12) + at Object.onceWrapper (events.js:273:13) + at IncomingMessage.emit (events.js:187:15) + at endReadableNT (_stream_readable.js:1094:12) at process._tickCallback (internal/process/next_tick.js:63:19) npm manifest: @@ -48,6 +48,7 @@ npm manifest: "@babel/preset-env": "^7.7.5", "@babel/register": "^7.7.4", "@babel/runtime": "^7.7.6", + "client-sessions": "^0.8.0", "eslint": "^6.7.2", "jest": "^24.9.0", "nodemon": "^2.0.1", @@ -1699,6 +1700,13 @@ Lockfile: resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk= + client-sessions@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/client-sessions/-/client-sessions-0.8.0.tgz#a7d8c5558ad5d56f2a199f3533eb654b5df893fd" + integrity sha1-p9jFVYrV1W8qGZ81M+tlS134k/0= + dependencies: + cookies "^0.7.0" + cliui@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" @@ -1878,6 +1886,14 @@ Lockfile: resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA== + cookies@^0.7.0: + version "0.7.3" + resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.7.3.tgz#7912ce21fbf2e8c2da70cf1c3f351aecf59dadfa" + integrity sha512-+gixgxYSgQLTaTIilDHAdlNPZDENDQernEMiIcZpYYP14zgHsCt4Ce1FEjFtcp6GefhozebB6orvhAAWx/IS0A== + dependencies: + depd "~1.1.2" + keygrip "~1.0.3" + copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" @@ -3947,6 +3963,11 @@ Lockfile: resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.3.1.tgz#def12d9c941017fabfb00f873af95e9c99e1be87" integrity sha512-l3hLhffs9zqoDe8zjmb/mAN4B8VT3L56EUvKNqLFVs9YlFA+zx7ke1DO8STAdDyYNkeSo1nKmjuvQeI12So8Xw== + keygrip@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.0.3.tgz#399d709f0aed2bab0a059e0cdd3a5023a053e1dc" + integrity sha512-/PpesirAIfaklxUzp4Yb7xBper9MwP6hNRA6BGGUFCgbJ+BM5CKBtsoxinNXkLHAr+GXS1/lSlF2rP7cv5Fl+g== + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"