feat(products,types,pricing): allow scoping products by collection_id + pricing by currency_code (#4965)

* chore: allow scoping products by collection_id

* chore: scope money amounts by currency code + change category_ids to category_id

* chore: add an extra product to test without collection

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Riqwan Thamir
2023-09-07 09:06:50 +02:00
committed by GitHub
co-authored by Oli Juhl
parent b71776738c
commit 66bd9a835c
11 changed files with 438 additions and 140 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@medusajs/product": patch
"@medusajs/types": patch
"@medusajs/pricing": patch
---
feat(products,types,pricing): allow scoping products by collection_id, allow scoping pricing by currency_code
@@ -39,7 +39,7 @@ describe("MoneyAmount Service", () => {
})
describe("list", () => {
it("list moneyAmounts", async () => {
it("should list all moneyAmounts", async () => {
const moneyAmountsResult = await service.list()
expect(moneyAmountsResult).toEqual([
@@ -58,7 +58,7 @@ describe("MoneyAmount Service", () => {
])
})
it("list moneyAmounts by id", async () => {
it("should list moneyAmounts by id", async () => {
const moneyAmountsResult = await service.list({
id: ["money-amount-USD"],
})
@@ -70,7 +70,7 @@ describe("MoneyAmount Service", () => {
])
})
it("list moneyAmounts with relations and selects", async () => {
it("should list moneyAmounts with relations and selects", async () => {
const moneyAmountsResult = await service.list(
{
id: ["money-amount-USD"],
@@ -94,6 +94,31 @@ describe("MoneyAmount Service", () => {
},
])
})
it("should list moneyAmounts scoped by currency_code", async () => {
const moneyAmountsResult = await service.list(
{
currency_code: ["USD"],
},
{
select: ["id", "min_quantity", "currency.code"],
relations: ["currency"],
}
)
const serialized = JSON.parse(JSON.stringify(moneyAmountsResult))
expect(serialized).toEqual([
{
id: "money-amount-USD",
min_quantity: "1",
currency_code: "USD",
currency: {
code: "USD",
},
},
])
})
})
describe("listAndCount", () => {
@@ -1,6 +1,6 @@
import { ProductTypes } from "@medusajs/types"
import faker from "faker"
import { Image } from "@models"
import faker from "faker"
export const buildProductOnlyData = ({
title,
@@ -46,6 +46,7 @@ export const buildProductAndRelationsData = ({
tags,
options,
variants,
collection_id,
}: Partial<ProductTypes.CreateProductDTO>) => {
const defaultOptionTitle = faker.commerce.productName()
return {
@@ -59,6 +60,7 @@ export const buildProductAndRelationsData = ({
images: (images ?? []) as Image[],
type: type ? { value: type } : { value: faker.commerce.productName() },
tags: tags ?? [{ value: "tag-1" }],
collection_id,
options: options ?? [
{
title: defaultOptionTitle,
@@ -23,6 +23,17 @@ export const productsData = [
},
],
},
{
id: "test-3",
title: "product 3",
status: ProductTypes.ProductStatus.PUBLISHED,
tags: [
{
id: "tag-3",
value: "Germany",
},
],
},
]
export const variantsData = [
@@ -20,6 +20,7 @@ export async function createProductAndTags(
title: string
status: ProductTypes.ProductStatus
tags?: { id: string; value: string }[]
collection_id?: string
}[]
) {
const products: any[] = data.map((productData) => {
@@ -1,5 +1,9 @@
import { MedusaModule } from "@medusajs/modules-sdk"
import { IProductModuleService } from "@medusajs/types"
import { kebabCase } from "@medusajs/utils"
import { knex } from "knex"
import { initialize } from "../../src"
import { EventBusService } from "../__fixtures__/event-bus"
import * as CustomRepositories from "../__fixtures__/module"
import {
buildProductAndRelationsData,
@@ -7,10 +11,6 @@ import {
} from "../__fixtures__/product"
import { productsData } from "../__fixtures__/product/data"
import { DB_URL, TestDatabase } from "../utils"
import { kebabCase } from "@medusajs/utils"
import { IProductModuleService } from "@medusajs/types"
import { knex } from "knex"
import { EventBusService } from "../__fixtures__/event-bus"
const sharedPgConnection = knex<any, any>({
client: "pg",
@@ -66,7 +66,7 @@ describe("Product module", function () {
it("should return a list of product", async () => {
const products = await module.list()
expect(products).toHaveLength(2)
expect(products).toHaveLength(3)
})
})
@@ -1,14 +1,20 @@
import { MedusaModule } from "@medusajs/modules-sdk"
import { Product, ProductCategory, ProductCollection, ProductType, ProductVariant } from "@models"
import { IProductModuleService, ProductTypes } from "@medusajs/types"
import { kebabCase } from "@medusajs/utils"
import {
Product,
ProductCategory,
ProductCollection,
ProductType,
ProductVariant,
} from "@models"
import { initialize } from "../../../../src"
import { DB_URL, TestDatabase } from "../../../utils"
import { buildProductAndRelationsData } from "../../../__fixtures__/product/data/create-product"
import { createProductCategories } from "../../../__fixtures__/product-category"
import { createCollections, createTypes } from "../../../__fixtures__/product"
import { EventBusService } from "../../../__fixtures__/event-bus"
import { createCollections, createTypes } from "../../../__fixtures__/product"
import { createProductCategories } from "../../../__fixtures__/product-category"
import { buildProductAndRelationsData } from "../../../__fixtures__/product/data/create-product"
import { DB_URL, TestDatabase } from "../../../utils"
const beforeEach_ = async () => {
await TestDatabase.setupDatabase()
@@ -21,14 +27,26 @@ const afterEach_ = async () => {
}
describe("ProductModuleService products", function () {
let productCollectionOne: ProductCollection
let productCollectionTwo: ProductCollection
const productCollectionsData = [
{
id: "test-1",
title: "col 1",
},
{
id: "test-2",
title: "col 2",
},
]
describe("update", function () {
let module: IProductModuleService
let productOne: Product
let productTwo: Product
let productCategoryOne: ProductCategory
let productCategoryTwo: ProductCategory
let productCollectionOne: ProductCollection
let productCollectionTwo: ProductCollection
let variantOne: ProductVariant
let variantTwo: ProductVariant
let variantThree: ProductVariant
@@ -37,22 +55,14 @@ describe("ProductModuleService products", function () {
let images = ["image-1"]
let eventBus
const productCategoriesData = [{
id: "test-1",
name: "category 1",
}, {
id: "test-2",
name: "category 2",
}]
const productCollectionsData = [
const productCategoriesData = [
{
id: "test-1",
title: "col 1",
name: "category 1",
},
{
id: "test-2",
title: "col 2",
name: "category 2",
},
]
@@ -67,10 +77,12 @@ describe("ProductModuleService products", function () {
},
]
const tagsData = [{
id: "tag-1",
value: "tag 1",
}]
const tagsData = [
{
id: "tag-1",
value: "tag 1",
},
]
beforeEach(async () => {
const testManager = await beforeEach_()
@@ -83,18 +95,15 @@ describe("ProductModuleService products", function () {
productCollectionOne = collections[0]
productCollectionTwo = collections[1]
const types = await createTypes(
testManager,
productTypesData,
)
const types = await createTypes(testManager, productTypesData)
productTypeOne = types[0]
productTypeTwo = types[1]
const categories = (await createProductCategories(
const categories = await createProductCategories(
testManager,
productCategoriesData
))
)
productCategoryOne = categories[0]
productCategoryTwo = categories[1]
@@ -140,14 +149,17 @@ describe("ProductModuleService products", function () {
MedusaModule.clearInstances()
eventBus = new EventBusService()
module = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
module = await initialize(
{
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
},
}, {
eventBusModuleService: eventBus
})
{
eventBusModuleService: eventBus,
}
)
})
afterEach(afterEach_)
@@ -161,14 +173,22 @@ describe("ProductModuleService products", function () {
const updateData = {
...data,
id: productOne.id,
title: "updated title"
title: "updated title",
}
const updatedProducts = await module.update([updateData])
expect(updatedProducts).toHaveLength(1)
const product = await module.retrieve(updateData.id, {
relations: ["images", "variants", "options", "options.values", "variants.options", "tags", "type",]
relations: [
"images",
"variants",
"options",
"options.values",
"variants.options",
"tags",
"type",
],
})
expect(product.images).toHaveLength(1)
@@ -236,7 +256,7 @@ describe("ProductModuleService products", function () {
})
it("should emit events through event bus", async () => {
const eventBusSpy = jest.spyOn(EventBusService.prototype, 'emit')
const eventBusSpy = jest.spyOn(EventBusService.prototype, "emit")
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
@@ -245,32 +265,36 @@ describe("ProductModuleService products", function () {
const updateData = {
...data,
id: productOne.id,
title: "updated title"
title: "updated title",
}
await module.update([updateData])
expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith([{
eventName: "product.updated",
data: { id: productOne.id }
}])
expect(eventBusSpy).toHaveBeenCalledWith([
{
eventName: "product.updated",
data: { id: productOne.id },
},
])
})
it("should add relationships to a product", async () => {
const updateData = {
id: productOne.id,
categories: [{
id: productCategoryOne.id
}],
categories: [
{
id: productCategoryOne.id,
},
],
collection_id: productCollectionOne.id,
type_id: productTypeOne.id
type_id: productTypeOne.id,
}
await module.update([updateData])
const product = await module.retrieve(updateData.id, {
relations: ["categories", "collection", "type"]
relations: ["categories", "collection", "type"],
})
expect(product).toEqual(
@@ -278,15 +302,15 @@ describe("ProductModuleService products", function () {
id: productOne.id,
categories: [
expect.objectContaining({
id: productCategoryOne.id
})
id: productCategoryOne.id,
}),
],
collection: expect.objectContaining({
id: productCollectionOne.id
id: productCollectionOne.id,
}),
type: expect.objectContaining({
id: productTypeOne.id
})
id: productTypeOne.id,
}),
})
)
})
@@ -296,22 +320,22 @@ describe("ProductModuleService products", function () {
id: productTwo.id,
type: {
id: productTypeOne.id,
value: productTypeOne.value
}
value: productTypeOne.value,
},
}
await module.update([updateData])
let product = await module.retrieve(updateData.id, {
relations: ["type"]
relations: ["type"],
})
expect(product).toEqual(
expect.objectContaining({
id: productTwo.id,
type: expect.objectContaining({
id: productTypeOne.id
})
id: productTypeOne.id,
}),
})
)
@@ -319,22 +343,22 @@ describe("ProductModuleService products", function () {
id: productTwo.id,
type: {
id: "new-type-id",
value: "new-type-value"
}
value: "new-type-value",
},
}
await module.update([updateData])
product = await module.retrieve(updateData.id, {
relations: ["type"]
relations: ["type"],
})
expect(product).toEqual(
expect.objectContaining({
id: productTwo.id,
type: expect.objectContaining({
...updateData.type
})
...updateData.type,
}),
})
)
})
@@ -347,9 +371,11 @@ describe("ProductModuleService products", function () {
const updateData = {
id: productTwo.id,
categories: [{
id: productCategoryTwo.id
}],
categories: [
{
id: productCategoryTwo.id,
},
],
collection_id: productCollectionTwo.id,
type_id: productTypeTwo.id,
tags: [newTagData],
@@ -358,7 +384,7 @@ describe("ProductModuleService products", function () {
await module.update([updateData])
const product = await module.retrieve(updateData.id, {
relations: ["categories", "collection", "tags", "type"]
relations: ["categories", "collection", "tags", "type"],
})
expect(product).toEqual(
@@ -366,21 +392,21 @@ describe("ProductModuleService products", function () {
id: productTwo.id,
categories: [
expect.objectContaining({
id: productCategoryTwo.id
})
id: productCategoryTwo.id,
}),
],
collection: expect.objectContaining({
id: productCollectionTwo.id
id: productCollectionTwo.id,
}),
tags: [
expect.objectContaining({
id: newTagData.id,
value: newTagData.value
})
value: newTagData.value,
}),
],
type: expect.objectContaining({
id: productTypeTwo.id
})
id: productTypeTwo.id,
}),
})
)
})
@@ -391,13 +417,13 @@ describe("ProductModuleService products", function () {
categories: [],
collection_id: null,
type_id: null,
tags: []
tags: [],
}
await module.update([updateData])
const product = await module.retrieve(updateData.id, {
relations: ["categories", "collection", "tags"]
relations: ["categories", "collection", "tags"],
})
expect(product).toEqual(
@@ -406,7 +432,7 @@ describe("ProductModuleService products", function () {
categories: [],
tags: [],
collection: null,
type: null
type: null,
})
)
})
@@ -415,7 +441,7 @@ describe("ProductModuleService products", function () {
let error
const updateData = {
id: "does-not-exist",
title: "test"
title: "test",
}
try {
@@ -431,18 +457,21 @@ describe("ProductModuleService products", function () {
const updateData = {
id: productTwo.id,
// Note: VariantThree is already assigned to productTwo, that should be deleted
variants: [{
id: variantTwo.id,
title: "updated-variant"
}, {
title: "created-variant"
}]
variants: [
{
id: variantTwo.id,
title: "updated-variant",
},
{
title: "created-variant",
},
],
}
await module.update([updateData])
const product = await module.retrieve(updateData.id, {
relations: ["variants"]
relations: ["variants"],
})
expect(product.variants).toHaveLength(2)
@@ -469,12 +498,15 @@ describe("ProductModuleService products", function () {
const updateData = {
id: productTwo.id,
// Note: VariantThree is already assigned to productTwo, that should be deleted
variants: [{
id: "does-not-exist",
title: "updated-variant"
}, {
title: "created-variant"
}]
variants: [
{
id: "does-not-exist",
title: "updated-variant",
},
{
title: "created-variant",
},
],
}
try {
@@ -484,10 +516,12 @@ describe("ProductModuleService products", function () {
}
await module.retrieve(updateData.id, {
relations: ["variants"]
relations: ["variants"],
})
expect(error.message).toEqual(`ProductVariant with id "does-not-exist" not found`)
expect(error.message).toEqual(
`ProductVariant with id "does-not-exist" not found`
)
})
})
@@ -502,14 +536,17 @@ describe("ProductModuleService products", function () {
MedusaModule.clearInstances()
eventBus = new EventBusService()
module = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
module = await initialize(
{
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
},
}, {
eventBusModuleService: eventBus
})
{
eventBusModuleService: eventBus,
}
)
})
afterEach(afterEach_)
@@ -590,7 +627,7 @@ describe("ProductModuleService products", function () {
})
it("should emit events through eventBus", async () => {
const eventBusSpy = jest.spyOn(EventBusService.prototype, 'emit')
const eventBusSpy = jest.spyOn(EventBusService.prototype, "emit")
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
@@ -599,10 +636,12 @@ describe("ProductModuleService products", function () {
const products = await module.create([data])
expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith([{
eventName: "product.created",
data: { id: products[0].id }
}])
expect(eventBusSpy).toHaveBeenCalledWith([
{
eventName: "product.created",
data: { id: products[0].id },
},
])
})
})
@@ -617,14 +656,17 @@ describe("ProductModuleService products", function () {
MedusaModule.clearInstances()
eventBus = new EventBusService()
module = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
module = await initialize(
{
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
},
}, {
eventBusModuleService: eventBus
})
{
eventBusModuleService: eventBus,
}
)
})
afterEach(afterEach_)
@@ -681,7 +723,7 @@ describe("ProductModuleService products", function () {
})
it("should emit events through eventBus", async () => {
const eventBusSpy = jest.spyOn(EventBusService.prototype, 'emit')
const eventBusSpy = jest.spyOn(EventBusService.prototype, "emit")
const data = buildProductAndRelationsData({
images,
thumbnail: images[0],
@@ -691,10 +733,12 @@ describe("ProductModuleService products", function () {
await module.softDelete([products[0].id])
expect(eventBusSpy).toHaveBeenCalledWith([{
eventName: "product.created",
data: { id: products[0].id }
}])
expect(eventBusSpy).toHaveBeenCalledWith([
{
eventName: "product.created",
data: { id: products[0].id },
},
])
})
})
@@ -770,4 +814,75 @@ describe("ProductModuleService products", function () {
}
})
})
describe("list", function () {
let module: IProductModuleService
let collections: ProductCollection
beforeEach(async () => {
const testManager = await beforeEach_()
const collections = await createCollections(
testManager,
productCollectionsData
)
productCollectionOne = collections[0]
productCollectionTwo = collections[1]
MedusaModule.clearInstances()
module = await initialize({
database: {
clientUrl: DB_URL,
schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA,
},
})
const productOneData = buildProductAndRelationsData({
collection_id: productCollectionOne.id,
})
const productTwoData = buildProductAndRelationsData({
collection_id: productCollectionTwo.id,
})
await module.create([productOneData, productTwoData])
})
afterEach(afterEach_)
it("should return a list of products scoped by collection id", async () => {
const productsWithCollectionOne = await module.list(
{ collection_id: productCollectionOne.id },
{
relations: ["collection"],
}
)
expect(productsWithCollectionOne).toHaveLength(1)
expect([
expect.objectContaining({
collection: expect.objectContaining({
id: productCollectionOne.id,
}),
}),
])
})
it("should returns empty array when querying for a collection that doesnt exist", async () => {
const products = await module.list(
{
categories: { id: ["collection-doesnt-exist-id"] },
},
{
select: ["title", "collection.title"],
relations: ["collection"],
}
)
expect(products).toEqual([])
})
})
})
@@ -1,11 +1,19 @@
import { Image, Product, ProductCategory, ProductVariant } from "@models"
import {
Image,
Product,
ProductCategory,
ProductCollection,
ProductVariant,
} from "@models"
import {
assignCategoriesToProduct,
buildProductOnlyData,
createCollections,
createImages,
createProductAndTags,
createProductVariants,
} from "../../../__fixtures__/product"
import {
categoriesData,
productsData,
@@ -30,6 +38,7 @@ describe("Product Service", () => {
let productOne: Product
let variants!: ProductVariant[]
let categories!: ProductCategory[]
let collections!: ProductCollection[]
beforeEach(async () => {
await TestDatabase.setupDatabase()
@@ -252,14 +261,14 @@ describe("Product Service", () => {
it("should list all products that are not deleted", async () => {
const products = await service.list()
expect(products).toHaveLength(1)
expect(products).toHaveLength(2)
expect(products[0].id).toEqual(product.id)
})
it("should list all products including the deleted", async () => {
const products = await service.list({}, { withDeleted: true })
expect(products).toHaveLength(2)
expect(products).toHaveLength(3)
})
})
@@ -412,6 +421,132 @@ describe("Product Service", () => {
})
})
describe("relation: collections", () => {
let workingProduct: Product
let workingProductTwo: Product
let workingCollection: ProductCollection
let workingCollectionTwo: ProductCollection
const collectionData = [
{
id: "test-1",
title: "col 1",
},
{
id: "test-2",
title: "col 2",
},
]
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
collections = await createCollections(testManager, collectionData)
workingCollection = (await testManager.findOne(
ProductCollection,
"test-1"
)) as ProductCollection
workingCollectionTwo = (await testManager.findOne(
ProductCollection,
"test-2"
)) as ProductCollection
products = await createProductAndTags(testManager, [
{
...productsData[0],
collection_id: workingCollection.id,
},
{
...productsData[1],
collection_id: workingCollectionTwo.id,
},
{
...productsData[2],
},
])
workingProduct = products.find((p) => p.id === "test-1") as Product
workingProductTwo = products.find((p) => p.id === "test-2") as Product
})
it("should filter by collection relation and scope fields", async () => {
const products = await service.list(
{
id: workingProduct.id,
collection_id: workingCollection.id,
},
{
select: ["title", "collection.title"],
relations: ["collection"],
}
)
const serialized = JSON.parse(JSON.stringify(products))
expect(serialized.length).toEqual(1)
expect(serialized).toEqual([
{
id: workingProduct.id,
title: workingProduct.title,
collection_id: workingCollection.id,
collection: {
id: workingCollection.id,
title: workingCollection.title,
},
},
])
})
it("should filter by collection when multiple collection ids are passed", async () => {
const products = await service.list(
{
collection_id: [workingCollection.id, workingCollectionTwo.id],
},
{
select: ["title", "collection.title"],
relations: ["collection"],
}
)
const serialized = JSON.parse(JSON.stringify(products))
expect(serialized.length).toEqual(2)
expect(serialized).toEqual([
{
id: workingProduct.id,
title: workingProduct.title,
collection_id: workingCollection.id,
collection: {
id: workingCollection.id,
title: workingCollection.title,
},
},
{
id: workingProductTwo.id,
title: workingProductTwo.title,
collection_id: workingCollectionTwo.id,
collection: {
id: workingCollectionTwo.id,
title: workingCollectionTwo.title,
},
},
])
})
it("should returns empty array when querying for a collection that doesnt exist", async () => {
const products = await service.list(
{
id: workingProduct.id,
collection_id: "collection-doesnt-exist-id",
},
{
select: ["title", "collection.title"] as (keyof ProductDTO)[],
relations: ["collection"],
}
)
expect(products).toEqual([])
})
})
describe("relation: variants", () => {
beforeEach(async () => {
testManager = await TestDatabase.forkManager()
+10 -10
View File
@@ -72,17 +72,17 @@ export default class ProductService<TEntity extends Product = Product> {
config: FindConfig<ProductTypes.ProductDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TEntity[]> {
if (filters.category_ids) {
if (Array.isArray(filters.category_ids)) {
if (filters.category_id) {
if (Array.isArray(filters.category_id)) {
filters.categories = {
id: { $in: filters.category_ids },
id: { $in: filters.category_id },
}
} else {
filters.categories = {
id: filters.category_ids,
id: filters.category_id,
}
}
delete filters.category_ids
delete filters.category_id
}
const queryOptions = ModulesSdkUtils.buildQuery<Product>(filters, config)
@@ -98,17 +98,17 @@ export default class ProductService<TEntity extends Product = Product> {
config: FindConfig<ProductTypes.ProductDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[TEntity[], number]> {
if (filters.category_ids) {
if (Array.isArray(filters.category_ids)) {
if (filters.category_id) {
if (Array.isArray(filters.category_id)) {
filters.categories = {
id: { $in: filters.category_ids },
id: { $in: filters.category_id },
}
} else {
filters.categories = {
id: filters.category_ids,
id: filters.category_id,
}
}
delete filters.category_ids
delete filters.category_id
}
const queryOptions = ModulesSdkUtils.buildQuery<Product>(filters, config)
@@ -30,4 +30,5 @@ export interface UpdateMoneyAmountDTO {
export interface FilterableMoneyAmountProps
extends BaseFilterable<FilterableMoneyAmountProps> {
id?: string[]
currency_code?: string | string[]
}
+2 -1
View File
@@ -164,7 +164,8 @@ export interface FilterableProductProps
categories?: {
id?: string | string[] | OperatorMap<string>
}
category_ids?: string | string[] | OperatorMap<string>
category_id?: string | string[] | OperatorMap<string>
collection_id?: string | string[] | OperatorMap<string>
}
export interface FilterableProductTagProps