From 47d075351fa4fdeaf32d48f2bd7e72943a293d9b Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 10 Jan 2023 10:08:16 +0100 Subject: [PATCH] feat(medusa): Get route for admin product categories API (#2961) --- .changeset/lovely-knives-own.md | 6 + .../api/__tests__/admin/product-category.ts | 114 ++++++++++++++++++ integration-tests/api/factories/index.ts | 1 + .../simple-product-category-factory.ts | 12 ++ .../medusa-test-utils/src/mock-repository.js | 7 ++ packages/medusa/src/api/routes/admin/index.js | 2 + .../get-product-category.ts | 73 +++++++++++ .../routes/admin/product-categories/index.ts | 42 +++++++ .../feature-flags/product-categories.ts | 10 ++ .../medusa/src/models/product-category.ts | 4 +- .../src/repositories/product-category.ts | 5 + .../services/__tests__/product-category.ts | 49 ++++++++ .../medusa/src/services/product-category.ts | 70 +++++++++++ 13 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 .changeset/lovely-knives-own.md create mode 100644 integration-tests/api/__tests__/admin/product-category.ts create mode 100644 integration-tests/api/factories/simple-product-category-factory.ts create mode 100644 packages/medusa/src/api/routes/admin/product-categories/get-product-category.ts create mode 100644 packages/medusa/src/api/routes/admin/product-categories/index.ts create mode 100644 packages/medusa/src/loaders/feature-flags/product-categories.ts create mode 100644 packages/medusa/src/repositories/product-category.ts create mode 100644 packages/medusa/src/services/__tests__/product-category.ts create mode 100644 packages/medusa/src/services/product-category.ts diff --git a/.changeset/lovely-knives-own.md b/.changeset/lovely-knives-own.md new file mode 100644 index 0000000000..3d2b0528c8 --- /dev/null +++ b/.changeset/lovely-knives-own.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa": minor +"medusa-test-utils": patch +--- + +feat(medusa): Admin API endpoint to fetch a Product Category diff --git a/integration-tests/api/__tests__/admin/product-category.ts b/integration-tests/api/__tests__/admin/product-category.ts new file mode 100644 index 0000000000..46d94f66c7 --- /dev/null +++ b/integration-tests/api/__tests__/admin/product-category.ts @@ -0,0 +1,114 @@ +import path from "path" + +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" + +jest.setTimeout(30000) + +const adminHeaders = { + headers: { + Authorization: "Bearer test_token", + }, +} + +describe("/admin/product-categories", () => { + let medusaProcess + let dbConnection + let productCategory = null + let productCategoryChild = null + let productCategoryParent = null + let productCategoryChild2 = null + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + const [process, connection] = await startServerWithEnvironment({ + cwd, + env: { MEDUSA_FF_PRODUCT_CATEGORIES: true }, + }) + dbConnection = connection + medusaProcess = process + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + medusaProcess.kill() + }) + + describe("GET /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, + }) + + productCategoryChild = await simpleProductCategoryFactory(dbConnection, { + name: "category child", + handle: "category-child", + parent_category: productCategory, + }) + + productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { + name: "category child 2", + handle: "category-child-2", + parent_category: productCategoryChild, + }) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("gets product category with children tree and parent", async () => { + const api = useApi() + + const response = await api.get( + `/admin/product-categories/${productCategory.id}`, + adminHeaders + ) + + expect(response.data.product_category).toEqual( + expect.objectContaining({ + id: productCategory.id, + name: productCategory.name, + handle: productCategory.handle, + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + name: productCategoryParent.name, + handle: productCategoryParent.handle, + }), + category_children: [ + expect.objectContaining({ + id: productCategoryChild.id, + name: productCategoryChild.name, + handle: productCategoryChild.handle, + category_children: [ + expect.objectContaining({ + id: productCategoryChild2.id, + name: productCategoryChild2.name, + handle: productCategoryChild2.handle, + category_children: [] + }) + ] + }) + ] + }) + ) + + expect(response.status).toEqual(200) + }) + }) +}) diff --git a/integration-tests/api/factories/index.ts b/integration-tests/api/factories/index.ts index 277ec463c0..b0e578e81e 100644 --- a/integration-tests/api/factories/index.ts +++ b/integration-tests/api/factories/index.ts @@ -22,3 +22,4 @@ export * from "./simple-payment-collection-factory" export * from "./simple-order-edit-factory" export * from "./simple-order-item-change-factory" export * from "./simple-customer-factory" +export * from "./simple-product-category-factory" diff --git a/integration-tests/api/factories/simple-product-category-factory.ts b/integration-tests/api/factories/simple-product-category-factory.ts new file mode 100644 index 0000000000..30a10bbf71 --- /dev/null +++ b/integration-tests/api/factories/simple-product-category-factory.ts @@ -0,0 +1,12 @@ +import { Connection } from "typeorm" +import { ProductCategory } from "@medusajs/medusa" + +export const simpleProductCategoryFactory = async ( + connection: Connection, + data: Partial = {} +): Promise => { + const manager = connection.manager + const address = manager.create(ProductCategory, data) + + return await manager.save(address) +} diff --git a/packages/medusa-test-utils/src/mock-repository.js b/packages/medusa-test-utils/src/mock-repository.js index bb2cb52174..f0c88056ac 100644 --- a/packages/medusa-test-utils/src/mock-repository.js +++ b/packages/medusa-test-utils/src/mock-repository.js @@ -5,6 +5,7 @@ class MockRepo { remove, softRemove, find, + findDescendantsTree, findOne, findOneWithRelations, findOneOrFail, @@ -18,6 +19,7 @@ class MockRepo { this.delete_ = del; this.softRemove_ = softRemove; this.find_ = find; + this.findDescendantsTree_ = findDescendantsTree; this.findOne_ = findOne; this.findOneOrFail_ = findOneOrFail; this.save_ = save; @@ -67,6 +69,11 @@ class MockRepo { return this.findOne_(...args); } }); + findDescendantsTree = jest.fn().mockImplementation((...args) => { + if (this.findDescendantsTree_) { + return this.findDescendantsTree_(...args); + } + }); findOneOrFail = jest.fn().mockImplementation((...args) => { if (this.findOneOrFail_) { return this.findOneOrFail_(...args); diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 2f75a00778..ce23ec273a 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -37,6 +37,7 @@ import userRoutes, { unauthenticatedUserRoutes } from "./users" import variantRoutes from "./variants" import paymentCollectionRoutes from "./payment-collections" import paymentRoutes from "./payments" +import productCategoryRoutes from "./product-categories" import { parseCorsOrigins } from "medusa-core-utils" const route = Router() @@ -108,6 +109,7 @@ export default (app, container, config) => { variantRoutes(route) paymentCollectionRoutes(route) paymentRoutes(route) + productCategoryRoutes(route) return app } diff --git a/packages/medusa/src/api/routes/admin/product-categories/get-product-category.ts b/packages/medusa/src/api/routes/admin/product-categories/get-product-category.ts new file mode 100644 index 0000000000..1e907e5cae --- /dev/null +++ b/packages/medusa/src/api/routes/admin/product-categories/get-product-category.ts @@ -0,0 +1,73 @@ +import { Request, Response } from "express" + +import ProductCategoryService from "../../../../services/product-category" +import { FindParams } from "../../../../types/common" +import { defaultAdminProductCategoryRelations } from "." + +/** + * @oas [get] /product-categories/{id} + * operationId: "GetProductCategoriesCategory" + * summary: "Get a Product Category" + * description: "Retrieves a Product Category." + * 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.retrieve("pcat-id") + * .then(({ productCategory }) => { + * console.log(productCategory.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/admin/product-categories/{id}' \ + * --header 'Authorization: Bearer {api_token}' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Category + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * productCategory: + * $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) => { + const { id } = req.params + + const productCategoryService: ProductCategoryService = req.scope.resolve( + "productCategoryService" + ) + + const productCategory = await productCategoryService.retrieve(id, { + relations: defaultAdminProductCategoryRelations, + }) + + res.status(200).json({ product_category: productCategory }) +} + +export class GetProductCategoryParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/product-categories/index.ts b/packages/medusa/src/api/routes/admin/product-categories/index.ts new file mode 100644 index 0000000000..9e3f51746f --- /dev/null +++ b/packages/medusa/src/api/routes/admin/product-categories/index.ts @@ -0,0 +1,42 @@ +import { Router } from "express" +import middlewares, { transformQuery } from "../../../middlewares" +import getProductCategory, { + GetProductCategoryParams, +} from "./get-product-category" +import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" + +const route = Router() + +export default (app) => { + app.use( + "/product-categories", + isFeatureFlagEnabled("product_categories"), + route + ) + + route.get( + "/:id", + transformQuery(GetProductCategoryParams, { + defaultFields: defaultProductCategoryFields, + isList: false, + }), + middlewares.wrap(getProductCategory) + ) + + return app +} + +export * from "./get-product-category" + +export const defaultAdminProductCategoryRelations = [ + "parent_category", + "category_children", +] + +export const defaultProductCategoryFields = [ + "id", + "name", + "handle", + "is_active", + "is_internal", +] diff --git a/packages/medusa/src/loaders/feature-flags/product-categories.ts b/packages/medusa/src/loaders/feature-flags/product-categories.ts new file mode 100644 index 0000000000..e42a5efaec --- /dev/null +++ b/packages/medusa/src/loaders/feature-flags/product-categories.ts @@ -0,0 +1,10 @@ +import { FlagSettings } from "../../types/feature-flags" + +const ProductCategoryFeatureFlag: FlagSettings = { + key: "product_categories", + default_val: false, + env_key: "MEDUSA_FF_PRODUCT_CATEGORIES", + description: "[WIP] Enable the product categories feature", +} + +export default ProductCategoryFeatureFlag diff --git a/packages/medusa/src/models/product-category.ts b/packages/medusa/src/models/product-category.ts index 3823e1e60c..dea1054e60 100644 --- a/packages/medusa/src/models/product-category.ts +++ b/packages/medusa/src/models/product-category.ts @@ -54,10 +54,10 @@ export class ProductCategory extends SoftDeletableEntity { } /** - * @schema productCategory + * @schema ProductCategory * title: "ProductCategory" * description: "Represents a product category" - * x-resourceId: productCategory + * x-resourceId: ProductCategory * type: object * required: * - name diff --git a/packages/medusa/src/repositories/product-category.ts b/packages/medusa/src/repositories/product-category.ts new file mode 100644 index 0000000000..61310ae9dd --- /dev/null +++ b/packages/medusa/src/repositories/product-category.ts @@ -0,0 +1,5 @@ +import { EntityRepository, TreeRepository } from "typeorm" +import { ProductCategory } from "../models/product-category" + +@EntityRepository(ProductCategory) +export class ProductCategoryRepository extends TreeRepository {} diff --git a/packages/medusa/src/services/__tests__/product-category.ts b/packages/medusa/src/services/__tests__/product-category.ts new file mode 100644 index 0000000000..775558a442 --- /dev/null +++ b/packages/medusa/src/services/__tests__/product-category.ts @@ -0,0 +1,49 @@ +import { IdMap, MockRepository, MockManager } from "medusa-test-utils" +import ProductCategoryService from "../product-category" + +describe("ProductCategoryService", () => { + describe("retrieve", () => { + const productCategoryRepository = MockRepository({ + findOne: query => { + if (query.where.id === "not-found") { + return Promise.resolve(undefined) + } + + return Promise.resolve({ id: IdMap.getId("skinny-jeans") }) + }, + findDescendantsTree: productCategory => { + return Promise.resolve(productCategory) + } + }) + + const productCategoryService = new ProductCategoryService({ + manager: MockManager, + productCategoryRepository, + }) + + beforeEach(async () => { jest.clearAllMocks() }) + + it("successfully retrieves a product category", async () => { + const result = await productCategoryService.retrieve( + IdMap.getId("skinny-jeans") + ) + + expect(result.id).toEqual(IdMap.getId("skinny-jeans")) + expect(productCategoryRepository.findOne).toHaveBeenCalledTimes(1) + expect(productCategoryRepository.findDescendantsTree).toHaveBeenCalledTimes(1) + expect(productCategoryRepository.findOne).toHaveBeenCalledWith({ + where: { id: IdMap.getId("skinny-jeans") }, + }) + }) + + it("fails on not-found product category id", async () => { + const categoryResponse = await productCategoryService + .retrieve("not-found") + .catch((e) => e) + + expect(categoryResponse.message).toBe( + `ProductCategory with id: not-found was not found` + ) + }) + }) +}) diff --git a/packages/medusa/src/services/product-category.ts b/packages/medusa/src/services/product-category.ts new file mode 100644 index 0000000000..8198dfb00b --- /dev/null +++ b/packages/medusa/src/services/product-category.ts @@ -0,0 +1,70 @@ +import { isDefined, MedusaError } from "medusa-core-utils" +import { EntityManager } from "typeorm" +import { TransactionBaseService } from "../interfaces" +import { ProductCategory } from "../models" +import { ProductCategoryRepository } from "../repositories/product-category" +import { FindConfig, Selector } from "../types/common" +import { buildQuery } from "../utils" + +type InjectedDependencies = { + manager: EntityManager + productCategoryRepository: typeof ProductCategoryRepository +} + +/** + * Provides layer to manipulate product categories. + */ +class ProductCategoryService extends TransactionBaseService { + protected manager_: EntityManager + protected readonly productCategoryRepo_: typeof ProductCategoryRepository + protected transactionManager_: EntityManager | undefined + + constructor({ manager, productCategoryRepository }: InjectedDependencies) { + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) + this.manager_ = manager + + this.productCategoryRepo_ = productCategoryRepository + } + + /** + * Retrieves a product category by id. + * @param productCategoryId - the id of the product category to retrieve. + * @param config - the config of the product category to retrieve. + * @return the product category. + */ + async retrieve( + productCategoryId: string, + config: FindConfig = {} + ): Promise { + if (!isDefined(productCategoryId)) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `"productCategoryId" must be defined` + ) + } + + const query = buildQuery({ id: productCategoryId }, config) + const productCategoryRepo = this.manager_.getCustomRepository( + this.productCategoryRepo_ + ) + + const productCategory = await productCategoryRepo.findOne(query) + + if (!productCategory) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `ProductCategory with id: ${productCategoryId} was not found` + ) + } + + // Returns the productCategory with all of its descendants until the last child node + const productCategoryTree = await productCategoryRepo.findDescendantsTree( + productCategory + ) + + return productCategoryTree + } +} + +export default ProductCategoryService