fix: storefront product filtering (#1189)

* fix: allow multiple ids in list + expand, fields param

* fix: add filtering by title

* fix: adds integration test

* fix: adds integration test of product variant filtering

* fix: integration tests

* fix: unit tests

* fix: refactor query param parsing
This commit is contained in:
Sebastian Rindom authored and GitHub committed 2022-03-17 23:28:15 +01:00
1 parent 4be991c156
commit e3655b53f7
12 files changed
+324 -28

No files matched your search

@@ -111,3 +111,60 @@ Object {
], ],
} }
`; `;
exports[`/store/variants lists by title 1`] = `
Object {
"variants": Array [
Object {
"allow_backorder": false,
"barcode": null,
"created_at": Any<String>,
"deleted_at": null,
"ean": null,
"height": null,
"hs_code": null,
"id": Any<String>,
"inventory_quantity": 12,
"length": null,
"manage_inventory": true,
"material": null,
"metadata": null,
"mid_code": null,
"options": Array [
Object {
"created_at": Any<String>,
"deleted_at": null,
"id": Any<String>,
"metadata": null,
"option_id": Any<String>,
"updated_at": Any<String>,
"value": "Handcrafted",
"variant_id": Any<String>,
},
],
"origin_country": null,
"prices": Array [
Object {
"amount": 100,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": Any<String>,
},
],
"product": Any<Object>,
"product_id": Any<String>,
"sku": null,
"title": "test2",
"upc": null,
"updated_at": Any<String>,
"weight": null,
"width": null,
},
],
}
`;
@@ -280,3 +280,56 @@ Object {
}, },
} }
`; `;
exports[`/store/products list params works with expand and fields 1`] = `
Object {
"count": 2,
"limit": 1,
"offset": 0,
"products": Array [
Object {
"id": Any<String>,
"title": "testprod",
"variants": Array [
Object {
"allow_backorder": false,
"barcode": null,
"created_at": Any<String>,
"deleted_at": null,
"ean": null,
"height": null,
"hs_code": null,
"id": Any<String>,
"inventory_quantity": 10,
"length": null,
"manage_inventory": true,
"material": null,
"metadata": null,
"mid_code": null,
"origin_country": null,
"prices": Array [
Object {
"amount": 100,
"created_at": Any<String>,
"currency_code": "usd",
"deleted_at": null,
"id": Any<String>,
"region_id": null,
"sale_amount": null,
"updated_at": Any<String>,
"variant_id": Any<String>,
},
],
"product_id": Any<String>,
"sku": null,
"title": "test-variant",
"upc": null,
"updated_at": Any<String>,
"weight": null,
"width": null,
},
],
},
],
}
`;
@@ -2,6 +2,7 @@ const path = require("path")
const setupServer = require("../../../helpers/setup-server") const setupServer = require("../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api") const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db") const { initDb, useDb } = require("../../../helpers/use-db")
const { simpleProductFactory } = require("../../factories")
const productSeeder = require("../../helpers/product-seeder") const productSeeder = require("../../helpers/product-seeder")
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -24,6 +25,24 @@ describe("/store/variants", () => {
beforeEach(async () => { beforeEach(async () => {
try { try {
await productSeeder(dbConnection) await productSeeder(dbConnection)
await simpleProductFactory(
dbConnection,
{
title: "prod",
variants: [
{
title: "test1",
inventory_quantity: 10,
},
{
title: "test2",
inventory_quantity: 12,
},
],
},
100
)
} catch (err) { } catch (err) {
console.log(err) console.log(err)
throw err throw err
@@ -93,6 +112,43 @@ describe("/store/variants", () => {
}) })
}) })
it("lists by title", async () => {
const api = useApi()
const response = await api.get(
"/store/variants?title[]=test1&title[]=test2&inventory_quantity[gt]=10"
)
expect(response.data).toMatchSnapshot({
variants: [
{
id: expect.any(String),
title: "test2",
created_at: expect.any(String),
updated_at: expect.any(String),
options: [
{
created_at: expect.any(String),
updated_at: expect.any(String),
id: expect.any(String),
option_id: expect.any(String),
variant_id: expect.any(String),
},
],
prices: [
{
id: expect.any(String),
variant_id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
},
],
product: expect.any(Object),
product_id: expect.any(String),
},
],
})
})
it("/test-variant", async () => { it("/test-variant", async () => {
const api = useApi() const api = useApi()
@@ -4,6 +4,7 @@ const setupServer = require("../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api") const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db") const { initDb, useDb } = require("../../../helpers/use-db")
const { simpleProductFactory } = require("../../factories")
const productSeeder = require("../../helpers/store-product-seeder") const productSeeder = require("../../helpers/store-product-seeder")
const adminSeeder = require("../../helpers/admin-seeder") const adminSeeder = require("../../helpers/admin-seeder")
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -222,6 +223,74 @@ describe("/store/products", () => {
}) })
}) })
describe("list params", () => {
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
const p1 = await simpleProductFactory(
dbConnection,
{
title: "testprod",
status: "published",
variants: [{ title: "test-variant" }],
},
11
)
const p2 = await simpleProductFactory(
dbConnection,
{
title: "testprod3",
status: "published",
variants: [{ title: "test-variant1" }],
},
12
)
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("works with expand and fields", async () => {
const api = useApi()
const response = await api.get(
"/store/products?expand=variants,variants.prices&fields=id,title&limit=1"
)
expect(response.data).toMatchSnapshot({
products: [
{
id: expect.any(String),
variants: [
{
created_at: expect.any(String),
updated_at: expect.any(String),
id: expect.any(String),
product_id: expect.any(String),
prices: [
{
created_at: expect.any(String),
updated_at: expect.any(String),
id: expect.any(String),
variant_id: expect.any(String),
},
],
},
],
},
],
})
})
})
describe("/store/products/:id", () => { describe("/store/products/:id", () => {
beforeEach(async () => { beforeEach(async () => {
try { try {
@@ -16,6 +16,7 @@ import {
export type ProductFactoryData = { export type ProductFactoryData = {
id?: string id?: string
is_giftcard?: boolean is_giftcard?: boolean
status?: string
title?: string title?: string
type?: string type?: string
options?: { id: string; title: string }[] options?: { id: string; title: string }[]
@@ -54,6 +55,7 @@ export const simpleProductFactory = async (
const toSave = manager.create(Product, { const toSave = manager.create(Product, {
id: prodId, id: prodId,
type_id: typeId, type_id: typeId,
status: data.status,
title: data.title || faker.commerce.productName(), title: data.title || faker.commerce.productName(),
is_giftcard: data.is_giftcard || false, is_giftcard: data.is_giftcard || false,
discountable: !data.is_giftcard, discountable: !data.is_giftcard,
@@ -33,6 +33,8 @@ describe("GET /admin/products/:id", () => {
"id", "id",
"title", "title",
"subtitle", "subtitle",
"status",
"external_id",
"description", "description",
"handle", "handle",
"is_giftcard", "is_giftcard",
@@ -51,6 +53,7 @@ describe("GET /admin/products/:id", () => {
"material", "material",
"created_at", "created_at",
"updated_at", "updated_at",
"deleted_at",
"metadata", "metadata",
], ],
relations: [ relations: [
@@ -73,6 +73,8 @@ export const defaultAdminProductFields = [
"id", "id",
"title", "title",
"subtitle", "subtitle",
"status",
"external_id",
"description", "description",
"handle", "handle",
"is_giftcard", "is_giftcard",
@@ -91,6 +93,7 @@ export const defaultAdminProductFields = [
"material", "material",
"created_at", "created_at",
"updated_at", "updated_at",
"deleted_at",
"metadata", "metadata",
] ]
@@ -98,6 +101,8 @@ export const allowedAdminProductFields = [
"id", "id",
"title", "title",
"subtitle", "subtitle",
"status",
"external_id",
"description", "description",
"handle", "handle",
"is_giftcard", "is_giftcard",
@@ -116,6 +121,7 @@ export const allowedAdminProductFields = [
"material", "material",
"created_at", "created_at",
"updated_at", "updated_at",
"deleted_at",
"metadata", "metadata",
] ]
@@ -8,11 +8,11 @@ import {
ValidateNested, ValidateNested,
} from "class-validator" } from "class-validator"
import { omit, pickBy, identity } from "lodash" import { omit, pickBy, identity } from "lodash"
import { MedusaError } from "medusa-core-utils"
import { defaultStoreProductsRelations } from "." import { defaultStoreProductsRelations } from "."
import { ProductService } from "../../../../services" import { ProductService } from "../../../../services"
import { DateComparisonOperator } from "../../../../types/common" import { DateComparisonOperator } from "../../../../types/common"
import { validator } from "../../../../utils/validator" import { validator } from "../../../../utils/validator"
import { IsType } from "../../../../utils/validators/is-type"
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
/** /**
@@ -64,6 +64,8 @@ export default async (req, res) => {
const validated = await validator(StoreGetProductsParams, req.query) const validated = await validator(StoreGetProductsParams, req.query)
const filterableFields: StoreGetProductsParams = omit(validated, [ const filterableFields: StoreGetProductsParams = omit(validated, [
"fields",
"expand",
"limit", "limit",
"offset", "offset",
]) ])
@@ -71,8 +73,23 @@ export default async (req, res) => {
// get only published products for store endpoint // get only published products for store endpoint
filterableFields["status"] = ["published"] filterableFields["status"] = ["published"]
let includeFields: string[] = []
if (validated.fields) {
const set = new Set(validated.fields.split(","))
set.add("id")
includeFields = [...set]
}
let expandFields: string[] = []
if (validated.expand) {
expandFields = validated.expand.split(",")
}
const listConfig = { const listConfig = {
relations: defaultStoreProductsRelations, select: includeFields.length ? includeFields : undefined,
relations: expandFields.length
? expandFields
: defaultStoreProductsRelations,
skip: validated.offset, skip: validated.offset,
take: validated.limit, take: validated.limit,
} }
@@ -91,6 +108,14 @@ export default async (req, res) => {
} }
export class StoreGetProductsPaginationParams { export class StoreGetProductsPaginationParams {
@IsString()
@IsOptional()
fields?: string
@IsString()
@IsOptional()
expand?: string
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@@ -103,9 +128,9 @@ export class StoreGetProductsPaginationParams {
} }
export class StoreGetProductsParams extends StoreGetProductsPaginationParams { export class StoreGetProductsParams extends StoreGetProductsPaginationParams {
@IsString()
@IsOptional() @IsOptional()
id?: string @IsType([String, [String]])
id?: string | string[]
@IsString() @IsString()
@IsOptional() @IsOptional()
@@ -1,8 +1,12 @@
import { Type } from "class-transformer" import { Type } from "class-transformer"
import { omit } from "lodash"
import { IsInt, IsOptional, IsString } from "class-validator" import { IsInt, IsOptional, IsString } from "class-validator"
import { defaultStoreVariantRelations } from "." import { defaultStoreVariantRelations } from "."
import { FilterableProductVariantProps } from "../../../../types/product-variant"
import ProductVariantService from "../../../../services/product-variant" import ProductVariantService from "../../../../services/product-variant"
import { validator } from "../../../../utils/validator" import { validator } from "../../../../utils/validator"
import { IsType } from "../../../../utils/validators/is-type"
import { NumericalComparisonOperator } from "../../../../types/common"
/** /**
* @oas [get] /variants * @oas [get] /variants
@@ -29,17 +33,14 @@ import { validator } from "../../../../utils/validator"
* $ref: "#/components/schemas/product_variant" * $ref: "#/components/schemas/product_variant"
*/ */
export default async (req, res) => { export default async (req, res) => {
const { limit, offset, expand, ids } = await validator( const validated = await validator(StoreGetVariantsParams, req.query)
StoreGetVariantsParams, const { expand, offset, limit } = validated
req.query
)
let expandFields: string[] = [] let expandFields: string[] = []
if (expand) { if (expand) {
expandFields = expand.split(",") expandFields = expand.split(",")
} }
let selector = {}
const listConfig = { const listConfig = {
relations: expandFields.length relations: expandFields.length
? expandFields ? expandFields
@@ -48,14 +49,21 @@ export default async (req, res) => {
take: limit, take: limit,
} }
if (ids) { const filterableFields: FilterableProductVariantProps = omit(validated, [
selector = { id: ids.split(",") } "ids",
"limit",
"offset",
"expand",
])
if (validated.ids) {
filterableFields.id = validated.ids.split(",")
} }
const variantService: ProductVariantService = req.scope.resolve( const variantService: ProductVariantService = req.scope.resolve(
"productVariantService" "productVariantService"
) )
const variants = await variantService.list(selector, listConfig) const variants = await variantService.list(filterableFields, listConfig)
res.json({ variants }) res.json({ variants })
} }
@@ -78,4 +86,16 @@ export class StoreGetVariantsParams {
@IsOptional() @IsOptional()
@IsString() @IsString()
ids?: string ids?: string
@IsOptional()
@IsType([String, [String]])
id?: string | string[]
@IsOptional()
@IsType([String, [String]])
title?: string | string[]
@IsOptional()
@IsType([Number, NumericalComparisonOperator])
inventory_quantity?: number | NumericalComparisonOperator
} }
+14 -11
View File
@@ -13,6 +13,7 @@ import { Product } from "../models/product"
type DefaultWithoutRelations = Omit<FindManyOptions<Product>, "relations"> type DefaultWithoutRelations = Omit<FindManyOptions<Product>, "relations">
type CustomOptions = { type CustomOptions = {
select?: DefaultWithoutRelations["select"]
where?: DefaultWithoutRelations["where"] & { where?: DefaultWithoutRelations["where"] & {
tags?: FindOperator<ProductTag> tags?: FindOperator<ProductTag>
} }
@@ -77,9 +78,7 @@ export class ProductRepository extends Repository<Product> {
return [entities, count] return [entities, count]
} }
private getGroupedRelations( private getGroupedRelations(relations: Array<keyof Product>): {
relations: Array<keyof Product>
): {
[toplevel: string]: string[] [toplevel: string]: string[]
} { } {
const groupedRelations: { [toplevel: string]: string[] } = {} const groupedRelations: { [toplevel: string]: string[] } = {}
@@ -98,12 +97,17 @@ export class ProductRepository extends Repository<Product> {
private async queryProductsWithIds( private async queryProductsWithIds(
entityIds: string[], entityIds: string[],
groupedRelations: { [toplevel: string]: string[] }, groupedRelations: { [toplevel: string]: string[] },
withDeleted = false withDeleted = false,
select: (keyof Product)[] = []
): Promise<Product[]> { ): Promise<Product[]> {
const entitiesIdsWithRelations = await Promise.all( const entitiesIdsWithRelations = await Promise.all(
Object.entries(groupedRelations).map(([toplevel, rels]) => { Object.entries(groupedRelations).map(([toplevel, rels]) => {
let querybuilder = this.createQueryBuilder("products") let querybuilder = this.createQueryBuilder("products")
if (select && select.length) {
querybuilder.select(select.map((f) => `products.${f}`))
}
if (toplevel === "variants") { if (toplevel === "variants") {
querybuilder = querybuilder querybuilder = querybuilder
.leftJoinAndSelect( .leftJoinAndSelect(
@@ -193,13 +197,13 @@ export class ProductRepository extends Repository<Product> {
const entitiesIdsWithRelations = await this.queryProductsWithIds( const entitiesIdsWithRelations = await this.queryProductsWithIds(
entitiesIds, entitiesIds,
groupedRelations, groupedRelations,
idsOrOptionsWithoutRelations.withDeleted idsOrOptionsWithoutRelations.withDeleted,
idsOrOptionsWithoutRelations.select
) )
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
const entitiesToReturn = this.mergeEntitiesWithRelations( const entitiesToReturn =
entitiesAndRelations this.mergeEntitiesWithRelations(entitiesAndRelations)
)
return [entitiesToReturn, count] return [entitiesToReturn, count]
} }
@@ -240,9 +244,8 @@ export class ProductRepository extends Repository<Product> {
) )
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
const entitiesToReturn = this.mergeEntitiesWithRelations( const entitiesToReturn =
entitiesAndRelations this.mergeEntitiesWithRelations(entitiesAndRelations)
)
return entitiesToReturn return entitiesToReturn
} }
+3 -1
View File
@@ -327,7 +327,9 @@ class ProductService extends BaseService {
return existing.id return existing.id
} }
const created = productTypeRepository.create(type) const created = productTypeRepository.create({
value: type.value,
})
const result = await productTypeRepository.save(created) const result = await productTypeRepository.save(created)
return result.id return result.id
+4 -4
View File
@@ -70,8 +70,8 @@ export class FilterableProductVariantProps {
@IsType([String, [String], StringComparisonOperator]) @IsType([String, [String], StringComparisonOperator])
id?: string | string[] | StringComparisonOperator id?: string | string[] | StringComparisonOperator
@IsString() @IsType([String, [String]])
title?: string title?: string | string[]
@IsType([String, [String]]) @IsType([String, [String]])
product_id?: string | string[] product_id?: string | string[]
@@ -88,8 +88,8 @@ export class FilterableProductVariantProps {
@IsType([String]) @IsType([String])
upc?: string upc?: string
@IsNumber() @IsType([Number, NumericalComparisonOperator])
inventory_quantity?: number inventory_quantity?: number | NumericalComparisonOperator
@IsBoolean() @IsBoolean()
allow_backorder?: boolean allow_backorder?: boolean