Makes product variant operations go through product service

This commit is contained in:
Sebastian Rindom committed 2020-05-26 18:21:54 +02:00
1 parent 96377df101
commit 3183efa94f
33 files changed
+565 -804

No files matched your search

@@ -3,7 +3,6 @@ import middlewares from "../../middlewares"
import authRoutes from "./auth" import authRoutes from "./auth"
import productRoutes from "./products" import productRoutes from "./products"
import userRoutes from "./users" import userRoutes from "./users"
import productVariantRoutes from "./product-variants"
import regionRoutes from "./regions" import regionRoutes from "./regions"
import shippingOptionRoutes from "./shipping-options" import shippingOptionRoutes from "./shipping-options"
import shippingProfileRoutes from "./shipping-profiles" import shippingProfileRoutes from "./shipping-profiles"
@@ -36,7 +35,6 @@ export default (app, container) => {
shippingProfileRoutes(route) shippingProfileRoutes(route)
discountRoutes(route) discountRoutes(route)
orderRoutes(route) orderRoutes(route)
productVariantRoutes(route)
return app return app
} }
@@ -1,40 +0,0 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
describe("POST /admin/product-variants/:id/options", () => {
describe("successful add option value", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/product-variants/${IdMap.getId("testVariant")}/options`,
{
payload: {
option_id: IdMap.getId("testOption"),
value: "test",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service addOption", () => {
expect(ProductVariantServiceMock.addOptionValue).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.addOptionValue).toHaveBeenCalledWith(
IdMap.getId("testVariant"),
IdMap.getId("testOption"),
"test"
)
})
})
})
@@ -1,75 +0,0 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
describe("POST /admin/product-variants", () => {
describe("successful creation", () => {
let subject
beforeAll(async () => {
subject = await request("POST", "/admin/product-variants", {
payload: {
title: "Test Product Variant",
prices: [
{
currency_code: "DKK",
amount: 1234,
},
],
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns created product draft", () => {
expect(subject.body._id).toEqual(IdMap.getId("testVariant"))
})
it("calls service createDraft", () => {
expect(ProductVariantServiceMock.createDraft).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.createDraft).toHaveBeenCalledWith({
title: "Test Product Variant",
prices: [
{
currency_code: "DKK",
amount: 1234,
},
],
})
})
})
describe("invalid data returns error details", () => {
let subject
beforeAll(async () => {
subject = await request("POST", "/admin/products", {
payload: {
image: "image",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 400", () => {
expect(subject.status).toEqual(400)
})
it("returns error details", () => {
expect(subject.body.name).toEqual("invalid_data")
expect(subject.body.message[0].message).toEqual(`"title" is required`)
})
})
})
@@ -1,39 +0,0 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
describe("DELETE /admin/product-variants/:id/options", () => {
describe("successfully deletes option value", () => {
let subject
beforeAll(async () => {
subject = await request(
"DELETE",
`/admin/product-variants/${IdMap.getId("testVariant")}/options`,
{
payload: {
option_id: IdMap.getId("testOption"),
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service deleteOptionValue", () => {
expect(ProductVariantServiceMock.deleteOptionValue).toHaveBeenCalledTimes(
1
)
expect(ProductVariantServiceMock.deleteOptionValue).toHaveBeenCalledWith(
IdMap.getId("testVariant"),
IdMap.getId("testOption")
)
})
})
})
@@ -1,46 +0,0 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
describe("DELETE /admin/product-variants/:id", () => {
describe("successfully deletes a product variant", () => {
let subject
beforeAll(async () => {
subject = await request(
"DELETE",
`/admin/product-variants/${IdMap.getId("testVariant")}`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls ProductVariantService delete", () => {
expect(ProductVariantServiceMock.delete).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.delete).toHaveBeenCalledWith(
IdMap.getId("testVariant")
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns correct delete data", () => {
expect(subject.body).toEqual({
id: IdMap.getId("testVariant"),
object: "productVariant",
deleted: true,
})
})
})
})
@@ -1,38 +0,0 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
describe("GET /admin/product-variants/:id", () => {
describe("successfully gets a product variant", () => {
let subject
beforeAll(async () => {
subject = await request(
"GET",
`/admin/product-variants/${IdMap.getId("testVariant")}`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls productVariantService retrieve", () => {
expect(ProductVariantServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.retrieve).toHaveBeenCalledWith(
IdMap.getId("testVariant")
)
})
it("returns product", () => {
expect(subject.body._id).toEqual(IdMap.getId("testVariant"))
})
})
})
@@ -1,28 +0,0 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
describe("GET /admin/product-variants", () => {
describe("successfully lists product variants", () => {
let subject
beforeAll(async () => {
subject = await request("GET", `/admin/product-variants`, {
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("calls ProductVariantService list", () => {
expect(ProductVariantServiceMock.list).toHaveBeenCalledTimes(1)
})
it("returns 200 and product variants", () => {
expect(subject.status).toEqual(200)
expect(subject.body[0]._id).toEqual(IdMap.getId("testVariant"))
})
})
})
@@ -1,38 +0,0 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
describe("POST /admin/product-variants/:id/publish", () => {
describe("successful publish", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/product-variants/${IdMap.getId("publish")}/publish`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns product with published flag true", () => {
expect(subject.body.published).toEqual(true)
})
it("calls service publish", () => {
expect(ProductVariantServiceMock.publish).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.publish).toHaveBeenCalledWith(
IdMap.getId("publish")
)
})
})
})
@@ -1,77 +0,0 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
describe("POST /admin/product-variants/:id/prices", () => {
describe("successfully sets region price", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/product-variants/${IdMap.getId("testVariant")}/prices`,
{
payload: {
region_id: IdMap.getId("region-fr"),
amount: 100,
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service setCurrencyPrice", () => {
expect(ProductVariantServiceMock.setRegionPrice).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.setRegionPrice).toHaveBeenCalledWith(
IdMap.getId("testVariant"),
IdMap.getId("region-fr"),
100
)
})
})
describe("successfully sets currency price", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/product-variants/${IdMap.getId("testVariant")}/prices`,
{
payload: {
currency_code: "EUR",
amount: 100,
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service setCurrencyPrice", () => {
expect(ProductVariantServiceMock.setCurrencyPrice).toHaveBeenCalledTimes(
1
)
expect(ProductVariantServiceMock.setCurrencyPrice).toHaveBeenCalledWith(
IdMap.getId("testVariant"),
"EUR",
100
)
})
})
})
@@ -1,80 +0,0 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
describe("POST /admin/product-variants/:id", () => {
describe("successful update", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/product-variants/${IdMap.getId("testVariant")}`,
{
payload: {
title: "Test Product Variant Updated",
prices: [
{
currency_code: "DKK",
amount: 1234,
},
],
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service update", () => {
expect(ProductVariantServiceMock.update).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.update).toHaveBeenCalledWith(
IdMap.getId("testVariant"),
{
title: "Test Product Variant Updated",
prices: [
{
currency_code: "DKK",
amount: 1234,
},
],
}
)
})
})
describe("handles failed update operation", () => {
it("throws if metadata is to be updated", async () => {
try {
await request(
"POST",
`/admin/product-variants/${IdMap.getId("testVariant")}`,
{
payload: {
_id: IdMap.getId("testVariant"),
title: "Product 1",
metadata: "Test Description",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
} catch (error) {
expect(error.status).toEqual(400)
expect(error.message).toEqual(
"Use setMetadata to update metadata fields"
)
}
})
})
})
@@ -1,28 +0,0 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
option_id: Validator.objectId().required(),
value: Validator.string().required(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const productVariantService = req.scope.resolve("productVariantService")
const productVariant = await productVariantService.addOptionValue(
id,
value.option_id,
value.value
)
res.status(200).json(productVariant)
} catch (err) {
throw err
}
}
@@ -1,42 +0,0 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object()
.keys({
region_id: Validator.string(),
currency_code: Validator.string(),
amount: Validator.number().required(),
})
.xor("region_id", "currency_code")
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const productVariantService = req.scope.resolve("productVariantService")
if (value.region_id) {
const productVariant = await productVariantService.setRegionPrice(
id,
value.region_id,
value.amount
)
res.status(200).json(productVariant)
} else {
const productVariant = await productVariantService.setCurrencyPrice(
id,
value.currency_code,
value.amount
)
res.status(200).json(productVariant)
}
} catch (err) {
throw err
}
}
@@ -1,26 +0,0 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
option_id: Validator.objectId().required(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const productVariantService = req.scope.resolve("productVariantService")
const productVariant = await productVariantService.deleteOptionValue(
id,
value.option_id
)
res.status(200).json(productVariant)
} catch (err) {
throw err
}
}
@@ -1,16 +0,0 @@
export default async (req, res) => {
const { id } = req.params
try {
const productVariantService = req.scope.resolve("productVariantService")
await productVariantService.delete(id)
res.json({
id: id,
object: "productVariant",
deleted: true,
})
} catch (err) {
throw err
}
}
@@ -1,12 +0,0 @@
export default async (req, res) => {
const { id } = req.params
try {
const productVariantService = req.scope.resolve("productVariantService")
const productVariant = await productVariantService.retrieve(id)
res.json(productVariant)
} catch (error) {
throw error
}
}
@@ -1,41 +0,0 @@
import { Router } from "express"
import middlewares from "../../../middlewares"
const route = Router()
export default app => {
app.use("/product-variants", route)
route.post("/", middlewares.wrap(require("./create-product-variant").default))
route.post(
"/:id",
middlewares.wrap(require("./update-product-variant").default)
)
route.post(
"/:id/publish",
middlewares.wrap(require("./publish-product-variant").default)
)
route.post("/:id/prices", middlewares.wrap(require("./add-price").default))
route.post(
"/:id/options",
middlewares.wrap(require("./add-option-value").default)
)
route.delete(
"/:id/options",
middlewares.wrap(require("./delete-option-value").default)
)
route.delete(
"/:id",
middlewares.wrap(require("./delete-product-variant").default)
)
route.get("/:id", middlewares.wrap(require("./get-product-variant").default))
route.get("/", middlewares.wrap(require("./list-product-variants").default))
return app
}
@@ -1,10 +0,0 @@
export default async (req, res) => {
try {
const productVariantService = req.scope.resolve("productVariantService")
const productVariants = await productVariantService.list({})
res.json(productVariants)
} catch (error) {
throw error
}
}
@@ -1,12 +0,0 @@
export default async (req, res) => {
const { id } = req.params
try {
const productVariantService = req.scope.resolve("productVariantService")
const productVariant = await productVariantService.publish(id)
res.json(productVariant)
} catch (error) {
throw error
}
}
@@ -1,28 +0,0 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
region_id: Validator.objectId().required(),
amount: Validator.number().required(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const productVariantService = req.scope.resolve("productVariantService")
const productVariant = await productVariantService.setRegionPrice(
id,
value.regionId,
value.amount
)
res.status(200).json(productVariant)
} catch (err) {
throw err
}
}
@@ -1,39 +0,0 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
title: Validator.string().optional(),
prices: Validator.array()
.items({
currency_code: Validator.string().required(),
amount: Validator.number().required(),
})
.optional(),
options: Validator.array()
.items({
option_id: Validator.objectId().required(),
value: Validator.string().required(),
})
.optional(),
image: Validator.string().optional(),
inventory_quantity: Validator.number().optional(),
allow_backorder: Validator.boolean().optional(),
manage_inventory: Validator.boolean().optional(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const productVariantService = req.scope.resolve("productVariantService")
const productVariant = await productVariantService.update(id, value)
res.status(200).json(productVariant)
} catch (err) {
throw err
}
}
@@ -2,17 +2,24 @@ import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request" import { request } from "../../../../../helpers/test-request"
import { ProductServiceMock } from "../../../../../services/__mocks__/product" import { ProductServiceMock } from "../../../../../services/__mocks__/product"
describe("POST /admin/products/:id/variants/:variantId", () => { describe("POST /admin/products/:id/variants", () => {
describe("successful add variant", () => { describe("successful add variant", () => {
let subject let subject
beforeAll(async () => { beforeAll(async () => {
subject = await request( subject = await request(
"POST", "POST",
`/admin/products/${IdMap.getId( `/admin/products/${IdMap.getId("productWithOptions")}/variants`,
"productWithOptions"
)}/variants/${IdMap.getId("variant2")}`,
{ {
payload: {
title: "Test Product Variant",
prices: [
{
currency_code: "DKK",
amount: 1234,
},
],
},
adminSession: { adminSession: {
jwt: { jwt: {
userId: IdMap.getId("admin_user"), userId: IdMap.getId("admin_user"),
@@ -27,10 +34,18 @@ describe("POST /admin/products/:id/variants/:variantId", () => {
}) })
it("calls service addVariant", () => { it("calls service addVariant", () => {
expect(ProductServiceMock.addVariant).toHaveBeenCalledTimes(1) expect(ProductServiceMock.createVariant).toHaveBeenCalledTimes(1)
expect(ProductServiceMock.addVariant).toHaveBeenCalledWith( expect(ProductServiceMock.createVariant).toHaveBeenCalledWith(
IdMap.getId("productWithOptions"), IdMap.getId("productWithOptions"),
IdMap.getId("variant2") {
title: "Test Product Variant",
prices: [
{
currency_code: "DKK",
amount: 1234,
},
],
}
) )
}) })
@@ -27,8 +27,8 @@ describe("POST /admin/products/:id/variants/:variantId", () => {
}) })
it("calls service removeVariant", () => { it("calls service removeVariant", () => {
expect(ProductServiceMock.removeVariant).toHaveBeenCalledTimes(1) expect(ProductServiceMock.deleteVariant).toHaveBeenCalledTimes(1)
expect(ProductServiceMock.removeVariant).toHaveBeenCalledWith( expect(ProductServiceMock.deleteVariant).toHaveBeenCalledWith(
IdMap.getId("productWithOptions"), IdMap.getId("productWithOptions"),
IdMap.getId("variant1") IdMap.getId("variant1")
) )
@@ -0,0 +1,172 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { ProductServiceMock } from "../../../../../services/__mocks__/product"
import { ProductVariantServiceMock } from "../../../../../services/__mocks__/product-variant"
describe("POST /admin/products/:id/variants/:variantId", () => {
describe("successful updates variant prices", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request(
"POST",
`/admin/products/${IdMap.getId(
"productWithOptions"
)}/variants/${IdMap.getId("variant1")}`,
{
payload: {
title: "hi",
prices: [
{
region_id: IdMap.getId("region-fr"),
amount: 100,
},
{
currency_code: "DKK",
amount: 100,
},
],
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service removeVariant", () => {
expect(ProductVariantServiceMock.setCurrencyPrice).toHaveBeenCalledTimes(
1
)
expect(ProductVariantServiceMock.setCurrencyPrice).toHaveBeenCalledWith(
IdMap.getId("variant1"),
"DKK",
100
)
expect(ProductVariantServiceMock.setRegionPrice).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.setRegionPrice).toHaveBeenCalledWith(
IdMap.getId("variant1"),
IdMap.getId("region-fr"),
100
)
})
it("filters prices", () => {
expect(ProductVariantServiceMock.update).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.update).toHaveBeenCalledWith(
IdMap.getId("variant1"),
{
title: "hi",
}
)
})
it("returns decorated product with variant removed", () => {
expect(subject.body._id).toEqual(IdMap.getId("productWithOptions"))
expect(subject.body.decorated).toEqual(true)
})
})
describe("successful updates options", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request(
"POST",
`/admin/products/${IdMap.getId(
"productWithOptions"
)}/variants/${IdMap.getId("variant1")}`,
{
payload: {
options: [
{
option_id: IdMap.getId("option_id"),
value: 100,
},
],
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service removeVariant", () => {
expect(ProductServiceMock.updateOptionValue).toHaveBeenCalledTimes(1)
expect(ProductServiceMock.updateOptionValue).toHaveBeenCalledWith(
IdMap.getId("productWithOptions"),
IdMap.getId("variant1"),
IdMap.getId("option_id"),
100
)
})
it("returns decorated product with variant removed", () => {
expect(subject.body._id).toEqual(IdMap.getId("productWithOptions"))
expect(subject.body.decorated).toEqual(true)
})
})
describe("successful updates variant", () => {
let subject
beforeAll(async () => {
jest.clearAllMocks()
subject = await request(
"POST",
`/admin/products/${IdMap.getId(
"productWithOptions"
)}/variants/${IdMap.getId("variant1")}`,
{
payload: {
title: "hi",
inventory_quantity: 123,
allow_backorder: true,
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls variant update", () => {
expect(ProductVariantServiceMock.update).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.update).toHaveBeenCalledWith(
IdMap.getId("variant1"),
{
title: "hi",
inventory_quantity: 123,
allow_backorder: true,
}
)
})
it("returns decorated product with variant removed", () => {
expect(subject.body._id).toEqual(IdMap.getId("productWithOptions"))
expect(subject.body.decorated).toEqual(true)
})
})
})
@@ -1,6 +1,7 @@
import { MedusaError, Validator } from "medusa-core-utils" import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => { export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({ const schema = Validator.object().keys({
title: Validator.string().required(), title: Validator.string().required(),
prices: Validator.array() prices: Validator.array()
@@ -26,10 +27,19 @@ export default async (req, res) => {
} }
try { try {
const productVariantService = req.scope.resolve("productVariantService") const productService = req.scope.resolve("productService")
const productVariant = await productVariantService.createDraft(value) const product = await productService.createVariant(id, value)
const data = await productService.decorate(product, [
res.status(200).json(productVariant) "title",
"description",
"tags",
"handle",
"images",
"options",
"variants",
"published",
])
res.json(data)
} catch (err) { } catch (err) {
throw err throw err
} }
@@ -3,7 +3,7 @@ export default async (req, res) => {
try { try {
const productService = req.scope.resolve("productService") const productService = req.scope.resolve("productService")
const product = await productService.addVariant(id, variantId) const product = await productService.deleteVariant(id, variantId)
const data = await productService.decorate(product, [ const data = await productService.decorate(product, [
"title", "title",
"description", "description",
@@ -14,7 +14,7 @@ export default async (req, res) => {
"variants", "variants",
"published", "published",
]) ])
res.json(data) res.json(product)
} catch (err) { } catch (err) {
throw err throw err
} }
@@ -12,10 +12,17 @@ export default app => {
"/:id/publish", "/:id/publish",
middlewares.wrap(require("./publish-product").default) middlewares.wrap(require("./publish-product").default)
) )
route.post( route.post(
"/:id/variants/:variantId", "/:id/variants",
middlewares.wrap(require("./add-variant").default) middlewares.wrap(require("./create-variant").default)
) )
route.post(
"/:id/variants/:variant_id",
middlewares.wrap(require("./update-variant").default)
)
route.post( route.post(
"/:id/options/:optionId", "/:id/options/:optionId",
middlewares.wrap(require("./update-option").default) middlewares.wrap(require("./update-option").default)
@@ -24,7 +31,7 @@ export default app => {
route.delete( route.delete(
"/:id/variants/:variantId", "/:id/variants/:variantId",
middlewares.wrap(require("./remove-variant").default) middlewares.wrap(require("./delete-variant").default)
) )
route.delete("/:id", middlewares.wrap(require("./delete-product").default)) route.delete("/:id", middlewares.wrap(require("./delete-product").default))
route.delete( route.delete(
@@ -1,22 +0,0 @@
export default async (req, res) => {
const { id, variantId } = req.params
try {
const productService = req.scope.resolve("productService")
await productService.removeVariant(id, variantId)
let updatedProduct = await productService.retrieve(id)
updatedProduct = await productService.decorate(updatedProduct, [
"title",
"description",
"tags",
"handle",
"images",
"options",
"variants",
"published",
])
res.json(updatedProduct)
} catch (err) {
throw err
}
}
@@ -0,0 +1,91 @@
import _ from "lodash"
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id, variant_id } = req.params
const schema = Validator.object().keys({
title: Validator.string().optional(),
prices: Validator.array().items(
Validator.object()
.keys({
region_id: Validator.string(),
currency_code: Validator.string(),
amount: Validator.number().required(),
})
.xor("region_id", "currency_code")
),
options: Validator.array().items({
option_id: Validator.objectId().required(),
value: Validator.alternatives(
Validator.string(),
Validator.number()
).required(),
}),
image: Validator.string().optional(),
inventory_quantity: Validator.number().optional(),
allow_backorder: Validator.boolean().optional(),
manage_inventory: Validator.boolean().optional(),
metadata: Validator.object().optional(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const productService = req.scope.resolve("productService")
const productVariantService = req.scope.resolve("productVariantService")
if (value.prices && value.prices.length) {
for (const price of value.prices) {
if (price.region_id) {
await productVariantService.setRegionPrice(
variant_id,
price.region_id,
price.amount
)
} else {
await productVariantService.setCurrencyPrice(
variant_id,
price.currency_code,
price.amount
)
}
}
}
if (value.options && value.options.length) {
for (const option of value.options) {
await productService.updateOptionValue(
id,
variant_id,
option.option_id,
option.value
)
}
}
delete value.prices
delete value.options
if (!_.isEmpty(value)) {
await productVariantService.update(variant_id, value)
}
const product = await productService.retrieve(id)
const data = await productService.decorate(product, [
"title",
"description",
"tags",
"handle",
"images",
"options",
"variants",
"published",
])
res.json(data)
} catch (err) {
throw err
}
}
@@ -194,10 +194,30 @@ export const ProductVariantServiceMock = {
update: jest.fn().mockReturnValue(Promise.resolve()), update: jest.fn().mockReturnValue(Promise.resolve()),
setCurrencyPrice: jest.fn().mockReturnValue(Promise.resolve()), setCurrencyPrice: jest.fn().mockReturnValue(Promise.resolve()),
setRegionPrice: jest.fn().mockReturnValue(Promise.resolve()), setRegionPrice: jest.fn().mockReturnValue(Promise.resolve()),
updateOptionValue: jest.fn().mockReturnValue(Promise.resolve()),
addOptionValue: jest.fn().mockImplementation((variantId, optionId, value) => { addOptionValue: jest.fn().mockImplementation((variantId, optionId, value) => {
return Promise.resolve({}) return Promise.resolve({})
}), }),
list: jest.fn().mockImplementation(data => { list: jest.fn().mockImplementation(data => {
if (data._id && data._id.$in) {
return Promise.resolve(
data._id.$in.map(id => {
if (id === "1") {
return variant1
}
if (id === "2") {
return variant2
}
if (id === "3") {
return variant3
}
if (id === "4") {
return variant4
}
})
)
}
return Promise.resolve([testVariant]) return Promise.resolve([testVariant])
}), }),
deleteOptionValue: jest.fn().mockImplementation((variantId, optionId) => { deleteOptionValue: jest.fn().mockImplementation((variantId, optionId) => {
@@ -43,10 +43,10 @@ export const ProductServiceMock = {
delete: jest.fn().mockImplementation(_ => { delete: jest.fn().mockImplementation(_ => {
return Promise.resolve() return Promise.resolve()
}), }),
addVariant: jest.fn().mockImplementation((productId, variantId) => { createVariant: jest.fn().mockImplementation((productId, value) => {
return Promise.resolve(products.productWithOptions) return Promise.resolve(products.productWithOptions)
}), }),
removeVariant: jest.fn().mockImplementation((productId, variantId) => { deleteVariant: jest.fn().mockImplementation((productId, variantId) => {
return Promise.resolve(products.productWithOptions) return Promise.resolve(products.productWithOptions)
}), }),
decorate: jest.fn().mockImplementation((product, fields) => { decorate: jest.fn().mockImplementation((product, fields) => {
@@ -57,6 +57,7 @@ export const ProductServiceMock = {
return Promise.resolve(products.productWithOptions) return Promise.resolve(products.productWithOptions)
}), }),
updateOption: jest.fn().mockReturnValue(Promise.resolve()), updateOption: jest.fn().mockReturnValue(Promise.resolve()),
updateOptionValue: jest.fn().mockReturnValue(Promise.resolve()),
deleteOption: jest.fn().mockReturnValue(Promise.resolve()), deleteOption: jest.fn().mockReturnValue(Promise.resolve()),
retrieve: jest.fn().mockImplementation(productId => { retrieve: jest.fn().mockImplementation(productId => {
if (productId === IdMap.getId("product1")) { if (productId === IdMap.getId("product1")) {
+138 -40
View File
@@ -310,7 +310,7 @@ describe("ProductService", () => {
}) })
}) })
describe("addVariant", () => { describe("createVariant", () => {
const productService = new ProductService({ const productService = new ProductService({
productModel: ProductModelMock, productModel: ProductModelMock,
productVariantService: ProductVariantServiceMock, productVariantService: ProductVariantServiceMock,
@@ -321,68 +321,112 @@ describe("ProductService", () => {
}) })
it("add variant to product successfilly", async () => { it("add variant to product successfilly", async () => {
await productService.addVariant(IdMap.getId("variantProductId"), "1") await productService.createVariant(IdMap.getId("variantProductId"), {
title: "variant1",
options: [
{
option_id: IdMap.getId("color_id"),
value: "blue",
},
{
option_id: IdMap.getId("size_id"),
value: "160",
},
],
})
expect(ProductVariantServiceMock.createDraft).toBeCalledTimes(1)
expect(ProductVariantServiceMock.createDraft).toBeCalledWith({
title: "variant1",
options: [
{
option_id: IdMap.getId("color_id"),
value: "blue",
},
{
option_id: IdMap.getId("size_id"),
value: "160",
},
],
})
expect(ProductVariantServiceMock.retrieve).toBeCalledTimes(1)
expect(ProductVariantServiceMock.retrieve).toBeCalledWith("1")
expect(ProductModelMock.findOne).toBeCalledTimes(1) expect(ProductModelMock.findOne).toBeCalledTimes(1)
expect(ProductModelMock.findOne).toBeCalledWith({ expect(ProductModelMock.findOne).toBeCalledWith({
_id: IdMap.getId("variantProductId"), _id: IdMap.getId("variantProductId"),
}) })
expect(ProductModelMock.updateOne).toBeCalledTimes(1) expect(ProductModelMock.updateOne).toBeCalledTimes(1)
expect(ProductModelMock.updateOne).toBeCalledWith( expect(ProductModelMock.updateOne).toBeCalledWith(
{ _id: IdMap.getId("variantProductId") }, { _id: IdMap.getId("variantProductId") },
{ $push: { variants: "1" } } { $push: { variants: expect.stringMatching(/.*/) } }
) )
}) })
it("throws error if option id is not present in product", async () => { it("throws error if option id is not present in product", async () => {
try { await expect(
await productService.addVariant( productService.createVariant(IdMap.getId("variantProductId"), {
IdMap.getId("variantProductId"), title: "variant3",
"invalid_option" options: [
) {
} catch (err) { option_id: "invalid_id",
expect(err.message).toEqual( value: "blue",
"Variant options do not contain value for Color" },
) {
} option_id: IdMap.getId("size_id"),
value: "150",
},
],
})
).rejects.toThrow("Variant options do not contain value for Color")
}) })
it("throws error if product variant options is empty", async () => { it("throws error if product variant options is empty", async () => {
try { await expect(
await productService.addVariant( productService.createVariant(IdMap.getId("variantProductId"), {
IdMap.getId("variantProductId"), title: "variant3",
"empty_option" options: [],
) })
} catch (err) { ).rejects.toThrow(
expect(err.message).toEqual(
"Product options length does not match variant options length. Product has 2 and variant has 0." "Product options length does not match variant options length. Product has 2 and variant has 0."
) )
}
}) })
it("throws error if product options is empty and product variant contains options", async () => { it("throws error if product options is empty and product variant contains options", async () => {
try { await expect(
await productService.addVariant( productService.createVariant(IdMap.getId("emptyVariantProductId"), {
IdMap.getId("emptyVariantProductId"), title: "variant1",
"1" options: [
) {
} catch (err) { option_id: IdMap.getId("color_id"),
expect(err.message).toEqual( value: "blue",
},
{
option_id: IdMap.getId("size_id"),
value: "160",
},
],
})
).rejects.toThrow(
"Product options length does not match variant options length. Product has 0 and variant has 2." "Product options length does not match variant options length. Product has 0 and variant has 2."
) )
}
}) })
it("throws error if option values of added variant already exists", async () => { it("throws error if option values of added variant already exists", async () => {
try { await expect(
await productService.addVariant(IdMap.getId("productWithVariants"), "3") productService.createVariant(IdMap.getId("productWithVariants"), {
} catch (err) { title: "variant3",
expect(err.message).toEqual( options: [
"Variant with provided options already exists" {
) option_id: IdMap.getId("color_id"),
} value: "blue",
},
{
option_id: IdMap.getId("size_id"),
value: "150",
},
],
})
).rejects.toThrow("Variant with provided options already exists")
}) })
}) })
@@ -539,7 +583,7 @@ describe("ProductService", () => {
}) })
}) })
describe("removeVariant", () => { describe("deleteVariant", () => {
const productService = new ProductService({ const productService = new ProductService({
productModel: ProductModelMock, productModel: ProductModelMock,
productVariantService: ProductVariantServiceMock, productVariantService: ProductVariantServiceMock,
@@ -550,11 +594,14 @@ describe("ProductService", () => {
}) })
it("removes variant from product", async () => { it("removes variant from product", async () => {
await productService.removeVariant( await productService.deleteVariant(
IdMap.getId("productWithVariants"), IdMap.getId("productWithVariants"),
"1" "1"
) )
expect(ProductVariantServiceMock.delete).toBeCalledTimes(1)
expect(ProductVariantServiceMock.delete).toBeCalledWith("1")
expect(ProductModelMock.updateOne).toBeCalledTimes(1) expect(ProductModelMock.updateOne).toBeCalledTimes(1)
expect(ProductModelMock.updateOne).toBeCalledWith( expect(ProductModelMock.updateOne).toBeCalledWith(
{ _id: IdMap.getId("productWithVariants") }, { _id: IdMap.getId("productWithVariants") },
@@ -746,4 +793,55 @@ describe("ProductService", () => {
} }
}) })
}) })
describe("updateOptionValue", () => {
const productService = new ProductService({
productModel: ProductModelMock,
productVariantService: ProductVariantServiceMock,
})
afterEach(() => {
jest.clearAllMocks()
})
it("successfully updates an option value", async () => {
await productService.updateOptionValue(
IdMap.getId("productWithVariants"),
"1",
IdMap.getId("color_id"),
"Blue"
)
expect(ProductVariantServiceMock.updateOptionValue).toBeCalledTimes(1)
expect(ProductVariantServiceMock.updateOptionValue).toBeCalledWith(
"1",
IdMap.getId("color_id"),
"Blue"
)
})
it("throws product-variant relationship isn't valid", async () => {
await expect(
productService.updateOptionValue(
IdMap.getId("productWithFourVariants"),
"invalid_variant",
IdMap.getId("color_id"),
"Blue"
)
).rejects.toThrow("The variant could not be found in the product")
})
it("throws if combination exists", async () => {
await expect(
productService.updateOptionValue(
IdMap.getId("productWithFourVariants"),
"1",
IdMap.getId("color_id"),
"black"
)
).rejects.toThrow(
"A variant with the given option value combination already exist"
)
})
})
}) })
@@ -297,6 +297,28 @@ class ProductVariantService extends BaseService {
) )
} }
/**
* Updates variant's option value.
* 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 updateOptionValue(variantId, optionId, optionValue) {
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: variant._id, "options.option_id": optionId },
{ $set: { "options.$.option_id": `${optionValue}` } }
)
}
/** /**
* Adds option value to a varaint. * Adds option value to a varaint.
* Fails when product with variant does not exists or * Fails when product with variant does not exists or
+69 -5
View File
@@ -71,6 +71,16 @@ class ProductService extends BaseService {
return product return product
} }
/**
* Gets all variants belonging to a product.
* @param {string} productId - the id of the product to get variants from.
* @return {Promise} an array of variants
*/
async retrieveVariants(productId) {
const product = await this.retrieve(productId)
return this.productVariantService_.list({ _id: { $in: product.variants } })
}
/** /**
* Creates an unpublished product. * Creates an unpublished product.
* @param {object} product - the product to create * @param {object} product - the product to create
@@ -171,11 +181,9 @@ class ProductService extends BaseService {
* @param {string} variantId - the variant to add to the product * @param {string} variantId - the variant to add to the product
* @return {Promise} the result of update * @return {Promise} the result of update
*/ */
async addVariant(productId, variantId) { async createVariant(productId, variant) {
const product = await this.retrieve(productId) const product = await this.retrieve(productId)
const variant = await this.productVariantService_.retrieve(variantId)
if (product.options.length !== variant.options.length) { if (product.options.length !== variant.options.length) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
@@ -212,9 +220,11 @@ class ProductService extends BaseService {
) )
} }
const newVariant = await this.productVariantService_.createDraft(variant)
return this.productModel_.updateOne( return this.productModel_.updateOne(
{ _id: product._id }, { _id: product._id },
{ $push: { variants: variantId } } { $push: { variants: newVariant._id } }
) )
} }
@@ -479,9 +489,11 @@ class ProductService extends BaseService {
* @param {string} variantId - the variant to remove from product * @param {string} variantId - the variant to remove from product
* @return {Promise} the result of update * @return {Promise} the result of update
*/ */
async removeVariant(productId, variantId) { async deleteVariant(productId, variantId) {
const product = await this.retrieve(productId) const product = await this.retrieve(productId)
await this.productVariantService_.delete(variantId)
return this.productModel_.updateOne( return this.productModel_.updateOne(
{ _id: product._id }, { _id: product._id },
{ {
@@ -492,6 +504,58 @@ class ProductService extends BaseService {
) )
} }
async updateOptionValue(productId, variantId, optionId, value) {
const product = await this.retrieve(productId)
// Check if the product-to-variant relationship holds
if (!product.variants.includes(variantId)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The variant could not be found in the product"
)
}
// Retrieve all variants
const variants = await this.retrieveVariants(productId)
const toUpdate = variants.find(v => v._id.equals(variantId))
// Check if an update would create duplicate variants
const canUpdate = variants.every(v => {
// The variant we update is irrelevant
if (v._id.equals(variantId)) {
return true
}
// Check if the variant's options are identical to the variant we
// are updating
const hasMatchingOptions = v.options.every(option => {
if (option.option_id === optionId) {
return option.value === value
}
const toUpdateOption = toUpdate.options.find(
o => o.option_id === option.option_id
)
return toUpdateOption.value === option.value
})
return !hasMatchingOptions
})
if (!canUpdate) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"A variant with the given option value combination already exist"
)
}
return this.productVariantService_.updateOptionValue(
variantId,
optionId,
value
)
}
/** /**
* Decorates a product with product variants. * Decorates a product with product variants.
* @param {Product} product - the product to decorate. * @param {Product} product - the product to decorate.