Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(medusa): Products can be added to categories in batch request #3123

Merged
merged 15 commits into from
Jan 27, 2023
Merged
5 changes: 5 additions & 0 deletions .changeset/tidy-students-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---

feat(medusa): Products can be added to categories in batch request
128 changes: 126 additions & 2 deletions integration-tests/api/__tests__/admin/product-category.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import path from "path"
import { Product } from "@medusajs/medusa"
import { In } from "typeorm"

import startServerWithEnvironment from "../../../helpers/start-server-with-environment"
import { useApi } from "../../../helpers/use-api"
import { useDb } from "../../../helpers/use-db"
import adminSeeder from "../../helpers/admin-seeder"
import { simpleProductCategoryFactory } from "../../factories"
import {
simpleProductCategoryFactory,
simpleProductFactory,
} from "../../factories"

jest.setTimeout(30000)

Expand All @@ -27,7 +32,7 @@ describe("/admin/product-categories", () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_PRODUCT_CATEGORIES: true },
env: { MEDUSA_FF_PRODUCT_CATEGORIES: true }
})
dbConnection = connection
medusaProcess = process
Expand Down Expand Up @@ -462,4 +467,123 @@ describe("/admin/product-categories", () => {
)
})
})

describe("POST /admin/product-categories/:id/products/batch", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)

productCategory = await simpleProductCategoryFactory(dbConnection, {
id: "test-category",
name: "test category",
})
})

afterEach(async () => {
const db = useDb()
await db.teardown()
})

it("should add products to a product category", async () => {
const api = useApi()
const testProduct1 = await simpleProductFactory(dbConnection, {
id: "test-product-1",
title: "test product 1",
})

const testProduct2 = await simpleProductFactory(dbConnection, {
id: "test-product-2",
title: "test product 2",
})

const payload = {
product_ids: [{ id: testProduct1.id }, { id: testProduct2.id }],
riqwan marked this conversation as resolved.
Show resolved Hide resolved
}

const response = await api.post(
riqwan marked this conversation as resolved.
Show resolved Hide resolved
`/admin/product-categories/${productCategory.id}/products/batch`,
payload,
adminHeaders
)

expect(response.status).toEqual(200)
expect(response.data.product_category).toEqual(
expect.objectContaining({
id: productCategory.id,
created_at: expect.any(String),
updated_at: expect.any(String),
})
)

const products = await dbConnection.manager.find(Product, {
riqwan marked this conversation as resolved.
Show resolved Hide resolved
where: { id: In([testProduct1.id, testProduct2.id]) },
relations: ["categories"],
})

expect(products[0].categories).toEqual([
expect.objectContaining({
id: productCategory.id
})
])

expect(products[1].categories).toEqual([
expect.objectContaining({
id: productCategory.id
})
])
})

it("throws error when product ID is invalid", async () => {
const api = useApi()

const payload = {
product_ids: [{ id: "product-id-invalid" }],
}

const error = await api.post(
`/admin/product-categories/${productCategory.id}/products/batch`,
payload,
adminHeaders
).catch(e => e)

expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
errors: ["Products product-id-invalid do not exist"],
message: "Provided request body contains errors. Please check the data and retry the request"
})
})

it("throws error when category ID is invalid", async () => {
const api = useApi()
const payload = { product_ids: [] }

const error = await api.post(
`/admin/product-categories/invalid-category-id/products/batch`,
payload,
adminHeaders
).catch(e => e)

expect(error.response.status).toEqual(404)
expect(error.response.data).toEqual({
message: "ProductCategory with id: invalid-category-id was not found",
type: "not_found",
})
})

it("throws error trying to expand not allowed relations", async () => {
const api = useApi()
const payload = { product_ids: [] }

const error = await api.post(
`/admin/product-categories/invalid-category-id/products/batch?expand=products`,
payload,
adminHeaders
).catch(e => e)

expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
message: "Relations [products] are not valid",
type: "invalid_data",
})
})
})
})
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
import { NextFunction, Request, Response } from "express"
import { ProductService } from "../../../services"
import { ProductBatchSalesChannel } from "../../../types/sales-channels"

export function validateProductsExist(
getProducts: (req) => ProductBatchSalesChannel[] | undefined
type GetProductsRequiredParams = {
id: string
}

export function validateProductsExist<T extends GetProductsRequiredParams = GetProductsRequiredParams>(
getProducts: (req) => T[]
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
return async (req: Request, res: Response, next: NextFunction) => {
const products = getProducts(req)
const requestedProducts = getProducts(req)

if (!products?.length) {
if (!requestedProducts?.length) {
return next()
}

const productService: ProductService = req.scope.resolve("productService")

const productIds = products.map((product) => product.id)
const [existingProducts] = await productService.listAndCount({
id: productIds,
const productService = req.scope.resolve("productService")
const requestedProductIds = requestedProducts.map((product) => product.id)
const [productRecords] = await productService.listAndCount({
id: requestedProductIds,
})

const nonExistingProducts = productIds.filter(
(scId) => existingProducts.findIndex((sc) => sc.id === scId) === -1
const nonExistingProducts = requestedProductIds.filter(
(requestedProductId) =>
productRecords.findIndex(
(productRecord) => productRecord.id === requestedProductId
) === -1
)

if (nonExistingProducts.length) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { IsArray, ValidateNested } from "class-validator"
import { Request, Response } from "express"

import { EntityManager } from "typeorm"
import { ProductBatchProductCategory } from "../../../../types/product-category"
import { ProductCategoryService } from "../../../../services"
import { Type } from "class-transformer"
import { FindParams } from "../../../../types/common"

/**
* @oas [post] /product-categories/{id}/products/batch
* operationId: "PostProductCategoriesCategoryProductsBatch"
* summary: "Add Products to a category"
* description: "Assign a batch of products to a product category."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the Product Category.
riqwan marked this conversation as resolved.
Show resolved Hide resolved
* - (query) expand {string} (Comma separated) Category fields to be expanded in the response.
* - (query) fields {string} (Comma separated) Category fields to be retrieved in the response.
* requestBody:
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/AdminPostProductCategoriesCategoryProductsBatchReq"
* x-codegen:
* method: addProducts
riqwan marked this conversation as resolved.
Show resolved Hide resolved
* x-codeSamples:
* - lang: Shell
riqwan marked this conversation as resolved.
Show resolved Hide resolved
* label: cURL
* source: |
riqwan marked this conversation as resolved.
Show resolved Hide resolved
* curl --location \
* --request POST 'https://medusa-url.com/admin/product-categories/{product_category_id}/products/batch' \
* --header 'Authorization: Bearer {api_token}' \
* --header 'Content-Type: application/json' \
* --data-raw '{
* "product_ids": [
* {
* "id": "{product_id}"
* }
* ]
* }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Product Category
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* type: object
* properties:
* product_category:
* $ref: "#/components/schemas/ProductCategory"
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req: Request, res: Response): Promise<void> => {
const validatedBody =
req.validatedBody as AdminPostProductCategoriesCategoryProductsBatchReq

const { id } = req.params

const productCategoryService: ProductCategoryService = req.scope.resolve(
"productCategoryService"
)

const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => {
return await productCategoryService
.withTransaction(transactionManager)
.addProducts(
id,
validatedBody.product_ids.map((p) => p.id),
)
})

const productCategory = await productCategoryService.retrieve(
id,
req.retrieveConfig
)

res.status(200).json({ product_category: productCategory })
}

/**
* @schema AdminPostProductCategoriesCategoryProductsBatchReq
* type: object
* required:
* - product_ids
* properties:
* product_ids:
* description: The IDs of the products to add to the Product Category
* type: array
* items:
* type: object
* required:
* - id
* properties:
* id:
* type: string
* description: The ID of the product
*/
export class AdminPostProductCategoriesCategoryProductsBatchReq {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductBatchProductCategory)
product_ids: ProductBatchProductCategory[]
}

export class AdminPostProductCategoriesCategoryProductsBatchParams extends FindParams {}
Loading