From b2839e2e4dc0d9344fa2ac8d4d16b796def4c56d Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Thu, 12 Jan 2023 17:19:06 +0100 Subject: [PATCH] feat(medusa): Retrieve (service + controller) a product category (#3004) What: Introduces a store endpoint to retrieve a product category Why: This is part of a greater goal of allowing products to be added to multiple categories. How: - Creates an endpoint in store routes RESOLVES CORE-967 --- .changeset/empty-beers-pull.md | 5 + .../api/__tests__/store/product-category.ts | 145 ++++++++++++++++++ packages/medusa/src/api/routes/store/index.js | 2 + .../__tests__/get-product-category.ts | 70 +++++++++ .../get-product-category.ts | 88 +++++++++++ .../routes/store/product-categories/index.ts | 52 +++++++ .../services/__mocks__/product-category.js | 43 ++++++ .../medusa/src/services/product-category.ts | 10 +- .../medusa/src/utils/transformers/tree.ts | 47 ++++++ 9 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 .changeset/empty-beers-pull.md create mode 100644 integration-tests/api/__tests__/store/product-category.ts create mode 100644 packages/medusa/src/api/routes/store/product-categories/__tests__/get-product-category.ts create mode 100644 packages/medusa/src/api/routes/store/product-categories/get-product-category.ts create mode 100644 packages/medusa/src/api/routes/store/product-categories/index.ts create mode 100644 packages/medusa/src/services/__mocks__/product-category.js create mode 100644 packages/medusa/src/utils/transformers/tree.ts diff --git a/.changeset/empty-beers-pull.md b/.changeset/empty-beers-pull.md new file mode 100644 index 0000000000..1bcd033747 --- /dev/null +++ b/.changeset/empty-beers-pull.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): create a store endpoint to retrieve a product category diff --git a/integration-tests/api/__tests__/store/product-category.ts b/integration-tests/api/__tests__/store/product-category.ts new file mode 100644 index 0000000000..62026ddb47 --- /dev/null +++ b/integration-tests/api/__tests__/store/product-category.ts @@ -0,0 +1,145 @@ +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 { simpleProductCategoryFactory } from "../../factories" + +jest.setTimeout(30000) + +describe("/store/product-categories", () => { + let medusaProcess + let dbConnection + let productCategory = null + let productCategory2 = null + let productCategoryChild = null + let productCategoryParent = null + let productCategoryChild2 = null + let productCategoryChild3 = 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 /store/product-categories/:id", () => { + beforeEach(async () => { + productCategoryParent = await simpleProductCategoryFactory(dbConnection, { + name: "category parent", + is_active: true, + }) + + productCategory = await simpleProductCategoryFactory(dbConnection, { + name: "category", + parent_category: productCategoryParent, + is_active: true, + }) + + productCategoryChild = await simpleProductCategoryFactory(dbConnection, { + name: "category child", + parent_category: productCategory, + is_active: true, + }) + + productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { + name: "category child 2", + parent_category: productCategory, + is_internal: true, + is_active: true, + }) + + productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, { + name: "category child 3", + parent_category: productCategory, + is_active: false, + }) + }) + + 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( + `/store/product-categories/${productCategory.id}?fields=handle,name`, + ) + + expect(response.data.product_category).toEqual( + expect.objectContaining({ + id: productCategory.id, + handle: productCategory.handle, + name: productCategory.name, + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + handle: productCategoryParent.handle, + name: productCategoryParent.name, + }), + category_children: [ + expect.objectContaining({ + id: productCategoryChild.id, + handle: productCategoryChild.handle, + name: productCategoryChild.name, + }), + ] + }) + ) + + expect(response.status).toEqual(200) + }) + + it("throws error on querying not allowed fields", async () => { + const api = useApi() + + const error = await api.get( + `/store/product-categories/${productCategory.id}?fields=mpath`, + ).catch(e => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual('invalid_data') + expect(error.response.data.message).toEqual('Fields [mpath] are not valid') + }) + + it("throws error on querying for internal product category", async () => { + const api = useApi() + + const error = await api.get( + `/store/product-categories/${productCategoryChild2.id}`, + ).catch(e => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data.type).toEqual('not_found') + expect(error.response.data.message).toEqual( + `ProductCategory with id: ${productCategoryChild2.id} was not found` + ) + }) + + it("throws error on querying for inactive product category", async () => { + const api = useApi() + + const error = await api.get( + `/store/product-categories/${productCategoryChild3.id}`, + ).catch(e => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data.type).toEqual('not_found') + expect(error.response.data.message).toEqual( + `ProductCategory with id: ${productCategoryChild3.id} was not found` + ) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/index.js b/packages/medusa/src/api/routes/store/index.js index 9cd8a93a96..a7e8bcda07 100644 --- a/packages/medusa/src/api/routes/store/index.js +++ b/packages/medusa/src/api/routes/store/index.js @@ -17,6 +17,7 @@ import shippingOptionRoutes from "./shipping-options" import swapRoutes from "./swaps" import variantRoutes from "./variants" import paymentCollectionRoutes from "./payment-collections" +import productCategoryRoutes from "./product-categories" import { parseCorsOrigins } from "medusa-core-utils" const route = Router() @@ -52,6 +53,7 @@ export default (app, container, config) => { giftCardRoutes(route) returnReasonRoutes(route) paymentCollectionRoutes(route) + productCategoryRoutes(route) return app } diff --git a/packages/medusa/src/api/routes/store/product-categories/__tests__/get-product-category.ts b/packages/medusa/src/api/routes/store/product-categories/__tests__/get-product-category.ts new file mode 100644 index 0000000000..6f67a5959f --- /dev/null +++ b/packages/medusa/src/api/routes/store/product-categories/__tests__/get-product-category.ts @@ -0,0 +1,70 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { + defaultStoreProductCategoryRelations, + defaultStoreScope, + defaultStoreProductCategoryFields +} from ".." +import { + ProductCategoryServiceMock, + validProdCategoryId, + invalidProdCategoryId, +} from "../../../../../services/__mocks__/product-category" + +describe("GET /store/product-categories/:id", () => { + describe("get product category by id successfully", () => { + let subject + + beforeAll(async () => { + subject = await request("GET", `/store/product-categories/${IdMap.getId(validProdCategoryId)}`) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls retrieve from product category service", () => { + expect(ProductCategoryServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(ProductCategoryServiceMock.retrieve).toHaveBeenCalledWith( + IdMap.getId(validProdCategoryId), + { + relations: defaultStoreProductCategoryRelations, + select: defaultStoreProductCategoryFields, + }, + defaultStoreScope + ) + }) + + it("returns product category", () => { + expect(subject.body.product_category.id).toEqual(IdMap.getId(validProdCategoryId)) + }) + }) + + describe("returns 404 error when ID is invalid", () => { + let subject + + beforeAll(async () => { + subject = await request("GET", `/store/product-categories/${IdMap.getId(invalidProdCategoryId)}`) + }) + + afterAll(() => { + jest.clearAllMocks() + }) + + it("calls retrieve from product category service", () => { + expect(ProductCategoryServiceMock.retrieve).toHaveBeenCalledTimes(1) + expect(ProductCategoryServiceMock.retrieve).toHaveBeenCalledWith( + IdMap.getId(invalidProdCategoryId), + { + relations: defaultStoreProductCategoryRelations, + select: defaultStoreProductCategoryFields, + }, + defaultStoreScope + ) + }) + + it("throws not found error", () => { + expect(subject.body.type).toEqual("not_found") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/store/product-categories/get-product-category.ts b/packages/medusa/src/api/routes/store/product-categories/get-product-category.ts new file mode 100644 index 0000000000..060b766279 --- /dev/null +++ b/packages/medusa/src/api/routes/store/product-categories/get-product-category.ts @@ -0,0 +1,88 @@ +import { Request, Response } from "express" + +import ProductCategoryService from "../../../../services/product-category" +import { FindParams } from "../../../../types/common" +import { transformTreeNodesWithConfig } from "../../../../utils/transformers/tree" +import { defaultStoreProductCategoryRelations, defaultStoreScope } from "." + +/** + * @oas [get] /product-categories/{id} + * operationId: "GetProductCategoriesCategory" + * summary: "Get a Product Category" + * description: "Retrieves a Product Category." + * x-authenticated: false + * parameters: + * - (path) id=* {string} The ID of the Product Category + * - (query) expand {string} (Comma separated) Which fields should be expanded in each product category. + * - (query) fields {string} (Comma separated) Which fields should be retrieved in each 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.productCategories.retrieve("pcat-id") + * .then(({ productCategory }) => { + * console.log(productCategory.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/store/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: + * 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 { retrieveConfig } = req + + const productCategoryService: ProductCategoryService = req.scope.resolve( + "productCategoryService" + ) + + const productCategory = await productCategoryService.retrieve( + id, + retrieveConfig, + defaultStoreScope + ) + + res.status(200).json({ + // TODO: When we implement custom queries for tree paths in medusa, remove the transformer + // Adding this here since typeorm tree repo doesn't allow configs to be passed + // onto its children nodes. As an alternative, we are transforming the data post query. + product_category: transformTreeNodesWithConfig( + productCategory, + retrieveConfig, + defaultStoreScope + ), + }) +} + +export class StoreGetProductCategoryParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/store/product-categories/index.ts b/packages/medusa/src/api/routes/store/product-categories/index.ts new file mode 100644 index 0000000000..3fca8b315f --- /dev/null +++ b/packages/medusa/src/api/routes/store/product-categories/index.ts @@ -0,0 +1,52 @@ +import { Router } from "express" +import middlewares, { transformQuery } from "../../../middlewares" +import getProductCategory, { + StoreGetProductCategoryParams, +} from "./get-product-category" + +const route = Router() + +export default (app) => { + app.use("/product-categories", route) + + route.get( + "/:id", + transformQuery(StoreGetProductCategoryParams, { + defaultFields: defaultStoreProductCategoryFields, + allowedFields: allowedStoreProductCategoryFields, + defaultRelations: defaultStoreProductCategoryRelations, + isList: false, + }), + middlewares.wrap(getProductCategory) + ) + + return app +} + +export const defaultStoreProductCategoryRelations = [ + "parent_category", + "category_children", +] + +export const defaultStoreScope = { + is_internal: false, + is_active: true, +} + +export const defaultStoreProductCategoryFields = [ + "id", + "name", + "handle", + "created_at", + "updated_at", +] + +export const allowedStoreProductCategoryFields = [ + "id", + "name", + "handle", + "created_at", + "updated_at", +] + +export * from "./get-product-category" diff --git a/packages/medusa/src/services/__mocks__/product-category.js b/packages/medusa/src/services/__mocks__/product-category.js new file mode 100644 index 0000000000..4c0b403782 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/product-category.js @@ -0,0 +1,43 @@ +import { MedusaError } from "medusa-core-utils" +import { IdMap } from "medusa-test-utils" + +export const validProdCategoryId = "skinny-jeans" +export const invalidProdCategoryId = "not-found" + +export const ProductCategoryServiceMock = { + withTransaction: function () { + return this + }, + create: jest.fn().mockImplementation((data) => { + return Promise.resolve({ id: IdMap.getId(validProdCategoryId), ...data }) + }), + retrieve: jest.fn().mockImplementation((id) => { + if (id === IdMap.getId(invalidProdCategoryId)) { + throw new MedusaError(MedusaError.Types.NOT_FOUND, "ProductCategory not found") + } + + if (id === IdMap.getId(validProdCategoryId)) { + return Promise.resolve({ id: IdMap.getId(validProdCategoryId) }) + } + }), + delete: jest.fn().mockReturnValue(Promise.resolve()), + update: jest.fn().mockImplementation((id, data) => { + if (id === IdMap.getId(invalidProdCategoryId)) { + throw new MedusaError(MedusaError.Types.NOT_FOUND, "ProductCategory not found") + } + + return Promise.resolve(Object.assign({ id }, data)) + }), + list: jest.fn().mockImplementation((data) => { + return Promise.resolve([{ id: IdMap.getId(validProdCategoryId) }]) + }), + listAndCount: jest.fn().mockImplementation((data) => { + return Promise.resolve([[{ id: IdMap.getId(validProdCategoryId) }], 1]) + }), +} + +const mock = jest.fn().mockImplementation(() => { + return ProductCategoryServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/product-category.ts b/packages/medusa/src/services/product-category.ts index e9fbc29536..d60c5672aa 100644 --- a/packages/medusa/src/services/product-category.ts +++ b/packages/medusa/src/services/product-category.ts @@ -86,7 +86,8 @@ class ProductCategoryService extends TransactionBaseService { */ async retrieve( productCategoryId: string, - config: FindConfig = {} + config: FindConfig = {}, + selector: Selector = {} ): Promise { if (!isDefined(productCategoryId)) { throw new MedusaError( @@ -95,7 +96,8 @@ class ProductCategoryService extends TransactionBaseService { ) } - const query = buildQuery({ id: productCategoryId }, config) + const selectors = Object.assign({ id: productCategoryId }, selector) + const query = buildQuery(selectors, config) const productCategoryRepo = this.manager_.getCustomRepository( this.productCategoryRepo_ ) @@ -133,7 +135,7 @@ class ProductCategoryService extends TransactionBaseService { await this.eventBusService_ .withTransaction(manager) .emit(ProductCategoryService.Events.CREATED, { - id: productCategory.id + id: productCategory.id, }) return productCategory @@ -206,7 +208,7 @@ class ProductCategoryService extends TransactionBaseService { await this.eventBusService_ .withTransaction(manager) .emit(ProductCategoryService.Events.DELETED, { - id: productCategory.id + id: productCategory.id, }) }) } diff --git a/packages/medusa/src/utils/transformers/tree.ts b/packages/medusa/src/utils/transformers/tree.ts new file mode 100644 index 0000000000..81acd55277 --- /dev/null +++ b/packages/medusa/src/utils/transformers/tree.ts @@ -0,0 +1,47 @@ +import { pick } from "lodash" +import { isDefined } from "medusa-core-utils" +import { filter, isNull } from "lodash" + +// TODO: When we implement custom queries for tree paths in medusa, remove the transformer +// Adding this here since typeorm tree repo doesn't allow configs to be passed +// onto its children nodes. As an alternative, we are transforming the data post query. +export function transformTreeNodesWithConfig( + object, + config, + scope = {}, + isParentNode = false +) { + const selects = (config.select || []) as string[] + const relations = (config.relations || []) as string[] + const selectsAndRelations = selects.concat(relations) + + for (const [key, value] of Object.entries(scope)) { + const modelValue = object[key] + + if (isDefined(modelValue) && modelValue !== value) { + return null + } + } + + if (object.parent_category) { + object.parent_category = transformTreeNodesWithConfig( + object.parent_category, + config, + scope, + true + ) + } + + if (!isParentNode && (object.category_children || []).length > 0) { + object.category_children = object.category_children.map((child) => { + return transformTreeNodesWithConfig(child, config, scope) + }) + + object.category_children = filter( + object.category_children, + (el) => !isNull(el) + ) + } + + return pick(object, selectsAndRelations) +}