From 71fa60892cd7c00dd9cb8c222a1794ad6577fc1b Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 10 Jan 2023 15:28:46 +0100 Subject: [PATCH] feat(medusa): Nested Categories Admin Delete Endpoint (#2975) **What:** Introduces an admin endpoint that allows a user to delete a product category, given an ID. Why: This is part of a greater goal of allowing products to be added to multiple categories. How: - Creates a route on the admin scope to delete category - Creates a method in product category services to delete a category RESOLVES CORE-957 --- .changeset/olive-dots-whisper.md | 5 ++ .../api/__tests__/admin/product-category.ts | 74 +++++++++++++++- .../delete-product-category.ts | 88 +++++++++++++++++++ .../routes/admin/product-categories/index.ts | 5 ++ .../services/__tests__/product-category.ts | 60 +++++++++++++ .../medusa/src/services/product-category.ts | 32 +++++++ 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 .changeset/olive-dots-whisper.md create mode 100644 packages/medusa/src/api/routes/admin/product-categories/delete-product-category.ts diff --git a/.changeset/olive-dots-whisper.md b/.changeset/olive-dots-whisper.md new file mode 100644 index 0000000000..9aac24becc --- /dev/null +++ b/.changeset/olive-dots-whisper.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): added admin delete category endpoint diff --git a/integration-tests/api/__tests__/admin/product-category.ts b/integration-tests/api/__tests__/admin/product-category.ts index a42a1a95e4..0dccf11f75 100644 --- a/integration-tests/api/__tests__/admin/product-category.ts +++ b/integration-tests/api/__tests__/admin/product-category.ts @@ -217,7 +217,7 @@ describe("/admin/product-categories", () => { const response = await api.get( `/admin/product-categories?parent_category_id=${productCategoryParent.id}`, adminHeaders, - ).catch(e => e) + ) expect(response.status).toEqual(200) expect(response.data.count).toEqual(1) @@ -233,4 +233,76 @@ describe("/admin/product-categories", () => { expect(nullCategoryResponse.data.product_categories[0].id).toEqual(productCategoryParent.id) }) }) + + describe("DELETE /admin/product-categories/:id", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + + productCategoryParent = await simpleProductCategoryFactory(dbConnection, { + name: "category parent", + handle: "category-parent", + }) + + productCategory = await simpleProductCategoryFactory(dbConnection, { + name: "category", + handle: "category", + parent_category: productCategoryParent, + }) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("returns successfully with an invalid ID", async () => { + const api = useApi() + + const response = await api.delete( + `/admin/product-categories/invalid-id`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.id).toEqual("invalid-id") + expect(response.data.deleted).toBeTruthy() + expect(response.data.object).toEqual("product_category") + }) + + it("throws a not allowed error for a category with children", async () => { + const api = useApi() + + const error = await api.delete( + `/admin/product-categories/${productCategoryParent.id}`, + adminHeaders + ).catch(e => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual("not_allowed") + expect(error.response.data.message).toEqual( + `Deleting ProductCategory (${productCategoryParent.id}) with category children is not allowed` + ) + }) + + it("deletes a product category with no children successfully", async () => { + const api = useApi() + + const deleteResponse = await api.delete( + `/admin/product-categories/${productCategory.id}`, + adminHeaders + ).catch(e => e) + + expect(deleteResponse.status).toEqual(200) + expect(deleteResponse.data.id).toEqual(productCategory.id) + expect(deleteResponse.data.deleted).toBeTruthy() + expect(deleteResponse.data.object).toEqual("product_category") + + const errorFetchingDeleted = await api.get( + `/admin/product-categories/${productCategory.id}`, + adminHeaders + ).catch(e => e) + + expect(errorFetchingDeleted.response.status).toEqual(404) + }) + }) }) diff --git a/packages/medusa/src/api/routes/admin/product-categories/delete-product-category.ts b/packages/medusa/src/api/routes/admin/product-categories/delete-product-category.ts new file mode 100644 index 0000000000..4ddf1521bf --- /dev/null +++ b/packages/medusa/src/api/routes/admin/product-categories/delete-product-category.ts @@ -0,0 +1,88 @@ +import { Request, Response } from "express" +import { EntityManager } from "typeorm" + +import { ProductCategoryService } from "../../../../services" + +/** + * @oas [delete] /product-categories/{id} + * operationId: "DeleteProductCategoriesCategory" + * summary: "Delete a Product Category" + * description: "Deletes a ProductCategory." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Product Category + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.productCategories.delete(product_category_id) + * .then(({ id, object, deleted }) => { + * console.log(id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request DELETE 'https://medusa-url.com/admin/product-categories/{id}' \ + * --header 'Authorization: Bearer {api_token}' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Product Category + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * description: The ID of the deleted product category. + * object: + * type: string + * description: The type of the object that was deleted. + * default: product_category + * deleted: + * type: boolean + * description: Whether the product category was deleted successfully or not. + * default: true + * "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) => { + 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) + .delete(id) + }) + + res.json({ + id: id, + object: "product_category", + deleted: true, + }) +} diff --git a/packages/medusa/src/api/routes/admin/product-categories/index.ts b/packages/medusa/src/api/routes/admin/product-categories/index.ts index 19b0c463dd..fc8f1f4930 100644 --- a/packages/medusa/src/api/routes/admin/product-categories/index.ts +++ b/packages/medusa/src/api/routes/admin/product-categories/index.ts @@ -1,6 +1,8 @@ import { Router } from "express" + import middlewares, { transformQuery } from "../../../middlewares" import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" +import deleteProductCategory from "./delete-product-category" import getProductCategory, { AdminGetProductCategoryParams, @@ -38,10 +40,13 @@ export default (app) => { middlewares.wrap(getProductCategory) ) + route.delete("/:id", middlewares.wrap(deleteProductCategory)) + return app } export * from "./get-product-category" +export * from "./delete-product-category" export * from "./list-product-categories" export const defaultAdminProductCategoryRelations = [ diff --git a/packages/medusa/src/services/__tests__/product-category.ts b/packages/medusa/src/services/__tests__/product-category.ts index 5ecbd8f441..6a97426557 100644 --- a/packages/medusa/src/services/__tests__/product-category.ts +++ b/packages/medusa/src/services/__tests__/product-category.ts @@ -96,4 +96,64 @@ describe("ProductCategoryService", () => { expect(count).toEqual(0) }) }) + + describe("delete", () => { + const productCategoryRepository = MockRepository({ + findOne: query => { + if (query.where.id === "not-found") { + return Promise.resolve(undefined) + } + + if (query.where.id === "with-children") { + return Promise.resolve({ + id: IdMap.getId("with-children"), + category_children: [{ + id: IdMap.getId("skinny-jeans"), + }] + }) + } + + return Promise.resolve({ + id: IdMap.getId("jeans"), + category_children: [] + }) + }, + findDescendantsTree: (productCategory) => { + return Promise.resolve(productCategory) + }, + }) + + const productCategoryService = new ProductCategoryService({ + manager: MockManager, + productCategoryRepository, + }) + + beforeEach(async () => { jest.clearAllMocks() }) + + it("successfully deletes a product category", async () => { + const result = await productCategoryService.delete( + IdMap.getId("jeans") + ) + + expect(productCategoryRepository.delete).toBeCalledTimes(1) + expect(productCategoryRepository.delete).toBeCalledWith(IdMap.getId("jeans")) + }) + + it("returns without failure on not-found product category id", async () => { + const categoryResponse = await productCategoryService + .delete("not-found") + + expect(categoryResponse).toBe(undefined) + }) + + it("fails on product category with children", async () => { + const categoryResponse = await productCategoryService + .delete("with-children") + .catch((e) => e) + + expect(categoryResponse.message).toBe( + `Deleting ProductCategory (with-children) with category children is not allowed` + ) + }) + }) }) diff --git a/packages/medusa/src/services/product-category.ts b/packages/medusa/src/services/product-category.ts index a29d2b44dc..92d564763c 100644 --- a/packages/medusa/src/services/product-category.ts +++ b/packages/medusa/src/services/product-category.ts @@ -98,6 +98,38 @@ class ProductCategoryService extends TransactionBaseService { return productCategoryTree } + + /** + * Deletes a product category + * + * @param productCategoryId is the id of the product category to delete + * @return a promise + */ + async delete(productCategoryId: string): Promise { + return await this.atomicPhase_(async (manager) => { + const productCategoryRepository: ProductCategoryRepository = + manager.getCustomRepository(this.productCategoryRepo_) + + const productCategory = await this.retrieve(productCategoryId, { + relations: ["category_children"], + }).catch((err) => void 0) + + if (!productCategory) { + return Promise.resolve() + } + + if (productCategory.category_children.length > 0) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Deleting ProductCategory (${productCategoryId}) with category children is not allowed` + ) + } + + await productCategoryRepository.delete(productCategory.id) + + return Promise.resolve() + }) + } } export default ProductCategoryService