feat(core-flows, product): options checks on product create/update (#9171)
**What** - validate that variants are unique with respect to options on product update/create and variant update/create - validate that the product has options upon creation - ensure variants have the same number of option values as the product has options - admin error handling - update tests --- FIXES FRMW-2707 CC-556
This commit is contained in:
@@ -74,9 +74,11 @@ export const buildProductAndRelationsData = ({
|
||||
{
|
||||
title: faker.commerce.productName(),
|
||||
sku: faker.commerce.productName(),
|
||||
options: {
|
||||
[defaultOptionTitle]: defaultOptionValue,
|
||||
},
|
||||
options: options
|
||||
? { [options[0].title]: options[0].values[0] }
|
||||
: {
|
||||
[defaultOptionTitle]: defaultOptionValue,
|
||||
},
|
||||
},
|
||||
],
|
||||
// TODO: add categories, must be created first
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IProductModuleService,
|
||||
ProductDTO,
|
||||
ProductVariantDTO,
|
||||
UpdateProductVariantDTO,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
CommonEvents,
|
||||
@@ -66,7 +67,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
id: "test-1",
|
||||
title: "variant 1",
|
||||
product_id: productOne.id,
|
||||
options: { size: "large" },
|
||||
options: { size: "large", color: "red" },
|
||||
} as CreateProductVariantDTO)
|
||||
|
||||
variantTwo = await service.createProductVariants({
|
||||
@@ -227,44 +228,6 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
)
|
||||
})
|
||||
|
||||
it("should upsert the options of a variant successfully", async () => {
|
||||
await service.upsertProductVariants([
|
||||
{
|
||||
id: variantOne.id,
|
||||
options: { size: "small" },
|
||||
},
|
||||
])
|
||||
|
||||
const productVariant = await service.retrieveProductVariant(
|
||||
variantOne.id,
|
||||
{
|
||||
relations: ["options"],
|
||||
}
|
||||
)
|
||||
expect(productVariant.options).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
value: "small",
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
expect(eventBusEmitSpy.mock.calls[0][0]).toHaveLength(1)
|
||||
expect(eventBusEmitSpy).toHaveBeenCalledWith(
|
||||
[
|
||||
composeMessage(ProductEvents.PRODUCT_VARIANT_UPDATED, {
|
||||
data: { id: variantOne.id },
|
||||
object: "product_variant",
|
||||
source: Modules.PRODUCT,
|
||||
action: CommonEvents.UPDATED,
|
||||
}),
|
||||
],
|
||||
{
|
||||
internal: true,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("should do a partial update on the options of a variant successfully", async () => {
|
||||
await service.updateProductVariants(variantOne.id, {
|
||||
options: { size: "small", color: "red" },
|
||||
@@ -311,7 +274,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
const data: CreateProductVariantDTO = {
|
||||
title: "variant 3",
|
||||
product_id: productOne.id,
|
||||
options: { size: "small" },
|
||||
options: { size: "small", color: "blue" },
|
||||
}
|
||||
|
||||
const variant = await service.createProductVariants(data)
|
||||
@@ -324,6 +287,9 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
expect.objectContaining({
|
||||
value: "small",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
value: "blue",
|
||||
}),
|
||||
]),
|
||||
})
|
||||
)
|
||||
@@ -357,7 +323,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
},
|
||||
{
|
||||
title: "color",
|
||||
values: ["red", "blue"],
|
||||
values: ["red", "yellow"],
|
||||
},
|
||||
],
|
||||
} as CreateProductDTO)
|
||||
@@ -366,12 +332,12 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
{
|
||||
title: "new variant",
|
||||
product_id: productOne.id,
|
||||
options: { size: "small" },
|
||||
options: { size: "small", color: "red" },
|
||||
},
|
||||
{
|
||||
title: "new variant",
|
||||
product_id: productThree.id,
|
||||
options: { size: "small" },
|
||||
options: { size: "small", color: "yellow" },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -389,6 +355,12 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
?.values?.find((v) => v.value === "small")?.id,
|
||||
value: "small",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: productOne.options
|
||||
.find((o) => o.title === "color")
|
||||
?.values?.find((v) => v.value === "red")?.id,
|
||||
value: "red",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -401,11 +373,125 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
?.values?.find((v) => v.value === "small")?.id,
|
||||
value: "small",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: productThree.options
|
||||
.find((o) => o.title === "color")
|
||||
?.values?.find((v) => v.value === "yellow")?.id,
|
||||
value: "yellow",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw if there is an existing variant with same options combination", async () => {
|
||||
let error
|
||||
|
||||
const productFour = await service.createProducts({
|
||||
id: "product-4",
|
||||
title: "product 4",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
options: [
|
||||
{
|
||||
title: "size",
|
||||
values: ["large", "small"],
|
||||
},
|
||||
{
|
||||
title: "color",
|
||||
values: ["red", "blue"],
|
||||
},
|
||||
],
|
||||
} as CreateProductDTO)
|
||||
|
||||
const data: CreateProductVariantDTO[] = [
|
||||
{
|
||||
title: "new variant",
|
||||
product_id: productFour.id,
|
||||
options: { size: "small", color: "red" },
|
||||
},
|
||||
]
|
||||
|
||||
const [variant] = await service.createProductVariants(data)
|
||||
|
||||
expect(variant).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "new variant",
|
||||
product_id: productFour.id,
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: productFour.options
|
||||
.find((o) => o.title === "size")
|
||||
?.values?.find((v) => v.value === "small")?.id,
|
||||
value: "small",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: productFour.options
|
||||
.find((o) => o.title === "color")
|
||||
?.values?.find((v) => v.value === "red")?.id,
|
||||
value: "red",
|
||||
}),
|
||||
]),
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await service.createProductVariants([
|
||||
{
|
||||
title: "new variant",
|
||||
product_id: productFour.id,
|
||||
options: { size: "small", color: "red" },
|
||||
},
|
||||
] as CreateProductVariantDTO[])
|
||||
} catch (e) {
|
||||
error = e
|
||||
}
|
||||
|
||||
expect(error.message).toEqual(
|
||||
`Variant (${variant.title}) with provided options already exists.`
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw if there is an existing variant with same options combination (on update)", async () => {
|
||||
const productFour = await service.createProducts({
|
||||
id: "product-4",
|
||||
title: "product 4",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
options: [
|
||||
{
|
||||
title: "size",
|
||||
values: ["large", "small"],
|
||||
},
|
||||
{
|
||||
title: "color",
|
||||
values: ["red", "blue"],
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "new variant 1",
|
||||
options: { size: "small", color: "red" },
|
||||
},
|
||||
{
|
||||
title: "new variant 2",
|
||||
options: { size: "small", color: "blue" },
|
||||
},
|
||||
],
|
||||
} as CreateProductDTO)
|
||||
|
||||
const error = await service
|
||||
.updateProductVariants(
|
||||
productFour.variants.find((v) => v.title === "new variant 2")!.id,
|
||||
{
|
||||
options: { size: "small", color: "red" },
|
||||
} as UpdateProductVariantDTO
|
||||
)
|
||||
.catch((err) => err)
|
||||
|
||||
expect(error.message).toEqual(
|
||||
`Variant (new variant 1) with provided options already exists.`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("softDelete variant", () => {
|
||||
|
||||
@@ -127,10 +127,17 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
id: "product-1",
|
||||
title: "product 1",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
options: [
|
||||
{
|
||||
title: "opt-title",
|
||||
values: ["val-1", "val-2"],
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
id: "variant-1",
|
||||
title: "variant 1",
|
||||
options: { "opt-title": "val-1" },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -156,6 +163,10 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
{
|
||||
id: "variant-2",
|
||||
title: "variant 2",
|
||||
options: {
|
||||
size: "large",
|
||||
color: "blue",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "variant-3",
|
||||
@@ -177,6 +188,12 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
const data = buildProductAndRelationsData({
|
||||
images,
|
||||
thumbnail: images[0].url,
|
||||
options: [
|
||||
{
|
||||
title: "opt-title",
|
||||
values: ["val-1", "val-2"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const variantTitle = data.variants[0].title
|
||||
@@ -195,7 +212,10 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
|
||||
productBefore.title = "updated title"
|
||||
productBefore.variants = [
|
||||
...productBefore.variants!,
|
||||
{
|
||||
...productBefore.variants[0]!,
|
||||
options: { "opt-title": "val-2" },
|
||||
},
|
||||
...data.variants,
|
||||
]
|
||||
productBefore.options = data.options
|
||||
@@ -541,6 +561,34 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
expect(error).toEqual(`Product with id: does-not-exist was not found`)
|
||||
})
|
||||
|
||||
it("should throw because variant doesn't have all options set", async () => {
|
||||
let error
|
||||
|
||||
try {
|
||||
await service.createProducts([
|
||||
{
|
||||
title: "Product with variants and options",
|
||||
options: [
|
||||
{ title: "opt1", values: ["1", "2"] },
|
||||
{ title: "opt2", values: ["3", "4"] },
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "missing option",
|
||||
options: { opt1: "1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
} catch (e) {
|
||||
error = e
|
||||
}
|
||||
|
||||
expect(error.message).toEqual(
|
||||
`Product "Product with variants and options" has variants with missing options: [missing option]`
|
||||
)
|
||||
})
|
||||
|
||||
it("should update, create and delete variants", async () => {
|
||||
const updateData = {
|
||||
id: productTwo.id,
|
||||
@@ -606,7 +654,7 @@ moduleIntegrationTestRunner<IProductModuleService>({
|
||||
)
|
||||
})
|
||||
|
||||
it("should createa variant with id that was passed if it does not exist", async () => {
|
||||
it("should create a variant with id that was passed if it does not exist", async () => {
|
||||
const updateData = {
|
||||
id: productTwo.id,
|
||||
// Note: VariantThree is already assigned to productTwo, that should be deleted
|
||||
|
||||
@@ -151,7 +151,6 @@ export default class ProductModuleService
|
||||
return joinerConfig
|
||||
}
|
||||
|
||||
// TODO: Add options validation, among other things
|
||||
// @ts-ignore
|
||||
createProductVariants(
|
||||
data: ProductTypes.CreateProductVariantDTO[],
|
||||
@@ -205,9 +204,24 @@ export default class ProductModuleService
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const variants = await this.productVariantService_.list(
|
||||
{
|
||||
product_id: [...new Set<string>(data.map((v) => v.product_id!))],
|
||||
},
|
||||
{
|
||||
relations: ["options"],
|
||||
},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const productVariantsWithOptions =
|
||||
ProductModuleService.assignOptionsToVariants(data, productOptions)
|
||||
|
||||
ProductModuleService.checkIfVariantWithOptionsAlreadyExists(
|
||||
productVariantsWithOptions as any,
|
||||
variants
|
||||
)
|
||||
|
||||
const createdVariants = await this.productVariantService_.create(
|
||||
productVariantsWithOptions,
|
||||
sharedContext
|
||||
@@ -324,6 +338,13 @@ export default class ProductModuleService
|
||||
{},
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const allVariants = await this.productVariantService_.list(
|
||||
{ product_id: variants.map((v) => v.product_id) },
|
||||
{ relations: ["options"] },
|
||||
sharedContext
|
||||
)
|
||||
|
||||
if (variants.length !== data.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
@@ -353,12 +374,20 @@ export default class ProductModuleService
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const productVariantsWithOptions =
|
||||
ProductModuleService.assignOptionsToVariants(
|
||||
variantsWithProductId,
|
||||
productOptions
|
||||
)
|
||||
|
||||
ProductModuleService.checkIfVariantWithOptionsAlreadyExists(
|
||||
productVariantsWithOptions as any,
|
||||
allVariants
|
||||
)
|
||||
|
||||
const { entities: productVariants, performedActions } =
|
||||
await this.productVariantService_.upsertWithReplace(
|
||||
ProductModuleService.assignOptionsToVariants(
|
||||
variantsWithProductId,
|
||||
productOptions
|
||||
),
|
||||
productVariantsWithOptions,
|
||||
{
|
||||
relations: ["options"],
|
||||
},
|
||||
@@ -1400,7 +1429,7 @@ export default class ProductModuleService
|
||||
d,
|
||||
sharedContext
|
||||
)
|
||||
this.validateProductPayload(normalized)
|
||||
this.validateProductCreatePayload(normalized)
|
||||
return normalized
|
||||
})
|
||||
)
|
||||
@@ -1466,7 +1495,7 @@ export default class ProductModuleService
|
||||
d,
|
||||
sharedContext
|
||||
)
|
||||
this.validateProductPayload(normalized)
|
||||
this.validateProductUpdatePayload(normalized)
|
||||
return normalized
|
||||
})
|
||||
)
|
||||
@@ -1522,18 +1551,26 @@ export default class ProductModuleService
|
||||
}
|
||||
|
||||
if (product.variants?.length) {
|
||||
const productVariantsWithOptions =
|
||||
ProductModuleService.assignOptionsToVariants(
|
||||
product.variants.map((v) => ({
|
||||
...v,
|
||||
product_id: upsertedProduct.id,
|
||||
})) ?? [],
|
||||
allOptions
|
||||
)
|
||||
|
||||
ProductModuleService.checkIfVariantsHaveUniqueOptionsCombinations(
|
||||
productVariantsWithOptions as any
|
||||
)
|
||||
|
||||
const { entities: productVariants } =
|
||||
await this.productVariantService_.upsertWithReplace(
|
||||
ProductModuleService.assignOptionsToVariants(
|
||||
product.variants?.map((v) => ({
|
||||
...v,
|
||||
product_id: upsertedProduct.id,
|
||||
})) ?? [],
|
||||
allOptions
|
||||
),
|
||||
productVariantsWithOptions,
|
||||
{ relations: ["options"] },
|
||||
sharedContext
|
||||
)
|
||||
|
||||
upsertedProduct.variants = productVariants
|
||||
|
||||
await this.productVariantService_.delete(
|
||||
@@ -1567,6 +1604,40 @@ export default class ProductModuleService
|
||||
}
|
||||
}
|
||||
|
||||
protected validateProductCreatePayload(
|
||||
productData: ProductTypes.CreateProductDTO
|
||||
) {
|
||||
this.validateProductPayload(productData)
|
||||
|
||||
const options = productData.options
|
||||
const missingOptionsVariants: string[] = []
|
||||
|
||||
if (options?.length) {
|
||||
productData.variants?.forEach((variant) => {
|
||||
options.forEach((option) => {
|
||||
if (!variant.options?.[option.title]) {
|
||||
missingOptionsVariants.push(variant.title)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (missingOptionsVariants.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Product "${
|
||||
productData.title
|
||||
}" has variants with missing options: [${missingOptionsVariants.join(
|
||||
", "
|
||||
)}]`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected validateProductUpdatePayload(productData: UpdateProductInput) {
|
||||
this.validateProductPayload(productData)
|
||||
}
|
||||
|
||||
protected async normalizeCreateProductInput(
|
||||
product: ProductTypes.CreateProductDTO,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
@@ -1686,11 +1757,27 @@ export default class ProductModuleService
|
||||
}
|
||||
|
||||
const variantsWithOptions = variants.map((variant: any) => {
|
||||
const variantOptions = Object.entries(variant.options ?? {}).map(
|
||||
const numOfProvidedVariantOptionValues = Object.keys(
|
||||
variant.options || {}
|
||||
).length
|
||||
|
||||
const productsOptions = options.filter(
|
||||
(o) => o.product_id === variant.product_id
|
||||
)
|
||||
|
||||
if (
|
||||
numOfProvidedVariantOptionValues &&
|
||||
productsOptions.length !== numOfProvidedVariantOptionValues
|
||||
) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Product has ${productsOptions.length} but there were ${numOfProvidedVariantOptionValues} provided option values for the variant: ${variant.title}.`
|
||||
)
|
||||
}
|
||||
|
||||
const variantOptions = Object.entries(variant.options || {}).map(
|
||||
([key, val]) => {
|
||||
const option = options.find(
|
||||
(o) => o.title === key && o.product_id === variant.product_id
|
||||
)
|
||||
const option = productsOptions.find((o) => o.title === key)
|
||||
|
||||
const optionValue = option?.values?.find(
|
||||
(v: any) => (v.value?.value ?? v.value) === val
|
||||
@@ -1721,4 +1808,78 @@ export default class ProductModuleService
|
||||
|
||||
return variantsWithOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that `data` doesn't create or update a variant to have same options combination
|
||||
* as an existing variant on the product.
|
||||
* @param data - create / update payloads
|
||||
* @param variants - existing variants
|
||||
* @protected
|
||||
*/
|
||||
protected static checkIfVariantWithOptionsAlreadyExists(
|
||||
data: ((
|
||||
| ProductTypes.CreateProductVariantDTO
|
||||
| ProductTypes.UpdateProductVariantDTO
|
||||
) & { options: { id: string }[]; product_id: string })[],
|
||||
variants: ProductVariant[]
|
||||
) {
|
||||
for (const variantData of data) {
|
||||
const existingVariant = variants.find((v) => {
|
||||
if (
|
||||
variantData.product_id! !== v.product_id ||
|
||||
!variantData.options?.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (variantData.options as unknown as { id: string }[])!.every(
|
||||
(optionValue) => {
|
||||
const variantOptionValue = v.options.find(
|
||||
(vo) => vo.id === optionValue.id
|
||||
)
|
||||
return !!variantOptionValue
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (existingVariant) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Variant (${existingVariant.title}) with provided options already exists.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that array of variants that we are upserting doesn't have variants with the same options.
|
||||
* @param variants -
|
||||
* @protected
|
||||
*/
|
||||
protected static checkIfVariantsHaveUniqueOptionsCombinations(
|
||||
variants: (ProductTypes.UpdateProductVariantDTO & {
|
||||
options: { id: string }[]
|
||||
})[]
|
||||
) {
|
||||
for (let i = 0; i < variants.length; i++) {
|
||||
const variant = variants[i]
|
||||
for (let j = i + 1; j < variants.length; j++) {
|
||||
const compareVariant = variants[j]
|
||||
|
||||
const exists = variant.options?.every(
|
||||
(optionValue) =>
|
||||
!!compareVariant.options.find(
|
||||
(compareOptionValue) => compareOptionValue.id === optionValue.id
|
||||
)
|
||||
)
|
||||
|
||||
if (exists) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Variant "${variant.title}" has same combination of option values as "${compareVariant.title}".`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user