feat: List products middleware (#6769)

This commit is contained in:
Oli Juhl
2024-03-22 14:59:21 +01:00
committed by GitHub
parent bb3cace0cd
commit 9e25e0c42e
9 changed files with 297 additions and 101 deletions
+188 -44
View File
@@ -4,7 +4,12 @@ const {
} = require("../../../helpers/create-admin-user")
const { breaking } = require("../../../helpers/breaking")
const { IdMap, medusaIntegrationTestRunner } = require("medusa-test-utils")
const { ModuleRegistrationName } = require("@medusajs/modules-sdk")
const { ModuleRegistrationName, Modules } = require("@medusajs/modules-sdk")
const {
createVariantPriceSet,
} = require("../../../modules/helpers/create-variant-price-set")
const { PriceListStatus, PriceListType } = require("@medusajs/types")
const { ContainerRegistrationKeys } = require("@medusajs/utils")
let productSeeder = undefined
let priceListSeeder = undefined
@@ -27,52 +32,17 @@ let {
jest.setTimeout(50000)
const productFixture = {
title: "Test fixture",
description: "test-product-description",
type: { value: "test-type" },
images: ["test-image.png", "test-image-2.png"],
tags: [{ value: "123" }, { value: "456" }],
options: breaking(
() => [{ title: "size" }, { title: "color" }],
() => [
{ title: "size", values: ["large"] },
{ title: "color", values: ["green"] },
]
),
variants: [
{
title: "Test variant",
inventory_quantity: 10,
prices: [
{
currency_code: "usd",
amount: 100,
},
{
currency_code: "eur",
amount: 45,
},
{
currency_code: "dkk",
amount: 30,
},
],
options: breaking(
() => [{ value: "large" }, { value: "green" }],
() => ({
size: "large",
color: "green",
})
),
},
],
}
medusaIntegrationTestRunner({
env: { MEDUSA_FF_PRODUCT_CATEGORIES: true },
testSuite: ({ dbConnection, getContainer, api }) => {
let v2Product
let pricingService
let productService
let scService
let remoteLink
let container
let productFixture
beforeAll(() => {
// Note: We have to lazily load everything because there are weird ordering issues when doing `require` of `@medusajs/medusa`
productSeeder = require("../../../helpers/product-seeder")
@@ -96,9 +66,51 @@ medusaIntegrationTestRunner({
})
beforeEach(async () => {
const container = getContainer()
container = getContainer()
await createAdminUser(dbConnection, adminHeaders, container)
productFixture = {
title: "Test fixture",
description: "test-product-description",
type: { value: "test-type" },
images: ["test-image.png", "test-image-2.png"],
tags: [{ value: "123" }, { value: "456" }],
options: breaking(
() => [{ title: "size" }, { title: "color" }],
() => [
{ title: "size", values: ["large"] },
{ title: "color", values: ["green"] },
]
),
variants: [
{
title: "Test variant",
inventory_quantity: 10,
prices: [
{
currency_code: "usd",
amount: 100,
},
{
currency_code: "eur",
amount: 45,
},
{
currency_code: "dkk",
amount: 30,
},
],
options: breaking(
() => [{ value: "large" }, { value: "green" }],
() => ({
size: "large",
color: "green",
})
),
},
],
}
// We want to seed another product for v2 that has pricing correctly wired up for all pricing-related tests.
v2Product = (
await breaking(
@@ -107,6 +119,11 @@ medusaIntegrationTestRunner({
await api.post("/admin/products", productFixture, adminHeaders)
)
)?.data?.product
pricingService = container.resolve(ModuleRegistrationName.PRICING)
productService = container.resolve(ModuleRegistrationName.PRODUCT)
scService = container.resolve(ModuleRegistrationName.SALES_CHANNEL)
remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK)
})
describe("/admin/products", () => {
@@ -1104,6 +1121,133 @@ medusaIntegrationTestRunner({
])
)
})
it("should return products filtered by price_list_id", async () => {
const priceList = await breaking(
async () => {
return await simplePriceListFactory(dbConnection, {
prices: [
{
variant_id: "test-variant",
amount: 100,
currency_code: "usd",
},
],
})
},
async () => {
const variantId = v2Product.variants[0].id
await pricingService.createRuleTypes([
{
name: "Region ID",
rule_attribute: "region_id",
},
])
const priceSet = await createVariantPriceSet({
container,
variantId,
})
const [priceList] = await pricingService.createPriceLists([
{
title: "Test price list",
description: "Test",
status: PriceListStatus.ACTIVE,
type: PriceListType.OVERRIDE,
prices: [
{
amount: 5000,
currency_code: "usd",
price_set_id: priceSet.id,
rules: {
region_id: "test-region",
},
},
],
},
])
return priceList
}
)
const res = await api.get(
`/admin/products?price_list_id[]=${priceList.id}`,
adminHeaders
)
expect(res.status).toEqual(200)
expect(res.data.products.length).toEqual(1)
expect(res.data.products).toEqual([
expect.objectContaining({
id: breaking(
() => "test-product",
() => v2Product.id
),
status: "draft",
}),
])
})
it("should return products filtered by sales_channel_id", async () => {
const { salesChannel, product } = await breaking(
async () => {
const product = await simpleProductFactory(dbConnection, {
id: "product_1",
title: "test title",
})
await simpleProductFactory(dbConnection, {
id: "product_2",
title: "test title 2",
})
const salesChannel = await simpleSalesChannelFactory(
dbConnection,
{
name: "test name",
description: "test description",
products: [product],
}
)
return { salesChannel, product }
},
async () => {
const salesChannel = await scService.create({
name: "Test channel",
description: "Lorem Ipsum",
})
await remoteLink.create({
[Modules.PRODUCT]: {
product_id: v2Product.id,
},
[Modules.SALES_CHANNEL]: {
sales_channel_id: salesChannel.id,
},
})
return { salesChannel, product: v2Product }
}
)
const res = await api.get(
`/admin/products?sales_channel_id[]=${salesChannel.id}`,
adminHeaders
)
expect(res.status).toEqual(200)
expect(res.data.products.length).toEqual(1)
expect(res.data.products).toEqual([
expect.objectContaining({
id: product.id,
status: "draft",
}),
])
})
})
describe("GET /admin/products/:id", () => {
@@ -2,6 +2,10 @@ import { transformBody, transformQuery } from "../../../api/middlewares"
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { authenticate } from "../../../utils/authenticate-middleware"
import * as QueryConfig from "./query-config"
import {
maybeApplyPriceListsFilter,
maybeApplySalesChannelsFilter,
} from "./utils"
import {
AdminGetProductsOptionsParams,
AdminGetProductsParams,
@@ -31,6 +35,8 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
AdminGetProductsParams,
QueryConfig.listProductQueryConfig
),
maybeApplySalesChannelsFilter(),
maybeApplyPriceListsFilter(),
],
},
{
@@ -86,6 +86,7 @@ export const defaultAdminProductFields = [
"*variants",
"*variants.prices",
"*variants.options",
"*sales_channels",
]
export const retrieveProductQueryConfig = {
@@ -2,74 +2,26 @@ import { createProductsWorkflow } from "@medusajs/core-flows"
import { CreateProductDTO } from "@medusajs/types"
import {
ContainerRegistrationKeys,
isString,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { MedusaContainer } from "medusa-core-utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../types/routing"
import { listPriceLists } from "../price-lists/queries"
import { refetchProduct, remapKeysForProduct, remapProduct } from "./helpers"
import { AdminGetProductsParams } from "./validators"
const applyVariantFiltersForPriceList = async (
scope: MedusaContainer,
filterableFields: AdminGetProductsParams
) => {
const filterByPriceListIds = filterableFields.price_list_id
const priceListVariantIds: string[] = []
// When filtering by price_list_id, we need use the remote query to get
// the variant IDs through the price list price sets.
if (Array.isArray(filterByPriceListIds)) {
const [priceLists] = await listPriceLists({
container: scope,
remoteQueryFields: ["price_set_money_amounts.price_set.variant.id"],
apiFields: ["prices.variant_id"],
variables: { filters: { id: filterByPriceListIds }, skip: 0, take: null },
})
priceListVariantIds.push(
...((priceLists
.map((priceList) => priceList.prices?.map((price) => price.variant_id))
.flat(2)
.filter(isString) || []) as string[])
)
delete filterableFields.price_list_id
}
if (priceListVariantIds.length) {
const existingVariantFilters = filterableFields.variants || {}
filterableFields.variants = {
...existingVariantFilters,
id: priceListVariantIds,
}
}
return filterableFields
}
export const GET = async (
req: AuthenticatedMedusaRequest<AdminGetProductsParams>,
res: MedusaResponse
) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
let filterableFields: AdminGetProductsParams = { ...req.filterableFields }
filterableFields = await applyVariantFiltersForPriceList(
req.scope,
filterableFields
)
const selectFields = remapKeysForProduct(req.remoteQueryConfig.fields ?? [])
const queryObject = remoteQueryObjectFromString({
entryPoint: "product",
variables: {
filters: filterableFields,
filters: req.filterableFields,
...req.remoteQueryConfig.pagination,
},
fields: selectFields,
@@ -0,0 +1,3 @@
export * from "./maybe-apply-price-lists-filter"
export * from "./maybe-apply-sales-channels-filter"
@@ -0,0 +1,51 @@
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { NextFunction } from "express"
import { MedusaRequest } from "../../../../types/routing"
import { AdminGetProductsParams } from "../validators"
export function maybeApplyPriceListsFilter() {
return async (req: MedusaRequest, _, next: NextFunction) => {
const filterableFields: AdminGetProductsParams = req.filterableFields
if (!filterableFields.price_list_id) {
return next()
}
const priceListIds = filterableFields.price_list_id
delete filterableFields.price_list_id
const queryObject = remoteQueryObjectFromString({
entryPoint: "price_list",
fields: ["price_set_money_amounts.price_set.variant.id"],
variables: {
id: priceListIds,
},
})
const remoteQuery = req.scope.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const variantIds: string[] = []
const priceLists = await remoteQuery(queryObject)
priceLists.forEach((priceList) => {
priceList.price_set_money_amounts?.forEach((psma) => {
const variantId = psma.price_set?.variant?.id
if (variantId) {
variantIds.push(variantId)
}
})
})
filterableFields.variants = {
...(filterableFields.variants ?? {}),
id: variantIds,
}
return next()
}
}
@@ -0,0 +1,36 @@
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { NextFunction } from "express"
import { MedusaRequest } from "../../../../types/routing"
import { AdminGetProductsParams } from "../validators"
export function maybeApplySalesChannelsFilter() {
return async (req: MedusaRequest, _, next: NextFunction) => {
const filterableFields: AdminGetProductsParams = req.filterableFields
if (!filterableFields.sales_channel_id) {
return next()
}
const salesChannelIds = filterableFields.sales_channel_id
delete filterableFields.sales_channel_id
const remoteQuery = req.scope.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const queryObject = remoteQueryObjectFromString({
entryPoint: "product_sales_channel",
fields: ["product_id"],
variables: { sales_channel_id: salesChannelIds },
})
const productsInSalesChannels = await remoteQuery(queryObject)
filterableFields.id = productsInSalesChannels.map((p) => p.product_id)
return next()
}
}
@@ -81,6 +81,13 @@ export class AdminGetProductsParams extends extendedFindParamsMixin({
@IsArray()
price_list_id?: string[]
/**
* Filter products by associated sales channel IDs.
*/
@IsOptional()
@IsArray()
sales_channel_id?: string[]
/**
* Filter products by their associated product collection's ID.
*/
@@ -107,12 +114,6 @@ export class AdminGetProductsParams extends extendedFindParamsMixin({
@IsObject()
variants?: Record<any, any>
// /**
// * Filter products by their associated sales channels' ID.
// */
// @FeatureFlagDecorators(SalesChannelFeatureFlag.key, [IsOptional(), IsArray()])
// sales_channel_id?: string[]
// /**
// * Filter products by their associated discount condition's ID.
// */
@@ -42,7 +42,7 @@ export const POST = async (
) => {
const salesChannelsData = [req.validatedBody]
const { errors } = await createSalesChannelsWorkflow(req.scope).run({
const { errors, result } = await createSalesChannelsWorkflow(req.scope).run({
input: { salesChannelsData },
throwOnError: false,
})
@@ -51,11 +51,13 @@ export const POST = async (
throw errors[0].error
}
const salesChannel = result[0]
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const queryObject = remoteQueryObjectFromString({
entryPoint: "sales_channels",
variables: { id: req.params.id },
variables: { id: salesChannel.id },
fields: req.remoteQueryConfig.fields,
})