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): Retrieve (service + controller) a product category #3004

Merged
merged 7 commits into from
Jan 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/empty-beers-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---

feat(medusa): create a store endpoint to retrieve a product category
145 changes: 145 additions & 0 deletions integration-tests/api/__tests__/store/product-category.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
riqwan marked this conversation as resolved.
Show resolved Hide resolved
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`
)
})
})
})
2 changes: 2 additions & 0 deletions packages/medusa/src/api/routes/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -52,6 +53,7 @@ export default (app, container, config) => {
giftCardRoutes(route)
returnReasonRoutes(route)
paymentCollectionRoutes(route)
productCategoryRoutes(route)

return app
}
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
Original file line number Diff line number Diff line change
@@ -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(
riqwan marked this conversation as resolved.
Show resolved Hide resolved
productCategory,
retrieveConfig,
defaultStoreScope
),
})
}

export class StoreGetProductCategoryParams extends FindParams {}
Original file line number Diff line number Diff line change
@@ -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"
Loading