feat: Create product category flow (#7034)

* feat: Create product category

* address PR comments
This commit is contained in:
Oli Juhl
2024-04-15 17:11:42 +02:00
committed by GitHub
parent fd83e75e4b
commit bc081a7777
17 changed files with 1470 additions and 1261 deletions
@@ -1,62 +1,64 @@
import path from "path" import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { Product, ProductCategory } from "@medusajs/medusa" import { IProductModuleService } from "@medusajs/types"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import { In } from "typeorm" import { In } from "typeorm"
import { breaking } from "../../../helpers/breaking"
import startServerWithEnvironment from "../../../environment-helpers/start-server-with-environment"
import { useApi } from "../../../environment-helpers/use-api"
import { useDb } from "../../../environment-helpers/use-db"
import adminSeeder from "../../../helpers/admin-seeder"
import { import {
simpleProductCategoryFactory, adminHeaders,
simpleProductFactory, createAdminUser,
} from "../../../factories" } from "../../../helpers/create-admin-user"
jest.setTimeout(30000) jest.setTimeout(30000)
const adminHeaders = { let { simpleProductCategoryFactory, simpleProductFactory } = {}
headers: { let { Product } = {}
"x-medusa-access-token": "test_token",
medusaIntegrationTestRunner({
env: {
MEDUSA_FF_PRODUCT_CATEGORIES: true,
// MEDUSA_FF_MEDUSA_V2: true,
}, },
} testSuite: ({ dbConnection, getContainer, api }) => {
let appContainer
let productCategory
let productCategory1
let productCategory2
let productCategoryChild
let productCategoryParent
let productCategoryChild0
let productCategoryChild1
let productCategoryChild2
let productCategoryChild3
describe("/admin/product-categories", () => { let productModuleService: IProductModuleService
let medusaProcess
let dbConnection
let productCategory!: ProductCategory
let productCategory1!: ProductCategory
let productCategory2!: ProductCategory
let productCategoryChild!: ProductCategory
let productCategoryParent!: ProductCategory
let productCategoryChild0!: ProductCategory
let productCategoryChild1!: ProductCategory
let productCategoryChild2!: ProductCategory
let productCategoryChild3!: ProductCategory
beforeAll(async () => { beforeAll(() => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) ;({
const [process, connection] = await startServerWithEnvironment({ simpleProductCategoryFactory,
cwd, simpleProductFactory,
env: { MEDUSA_FF_PRODUCT_CATEGORIES: true }, } = require("../../../factories"))
}) ;({ Product } = require("@medusajs/medusa"))
dbConnection = connection
medusaProcess = process
}) })
afterAll(async () => { beforeEach(async () => {
const db = useDb() appContainer = getContainer()
await db.shutdown()
medusaProcess.kill() productModuleService = appContainer.resolve(
ModuleRegistrationName.PRODUCT
)
await createAdminUser(dbConnection, adminHeaders, appContainer)
}) })
describe("GET /admin/product-categories/:id", () => { describe("GET /admin/product-categories/:id", () => {
beforeEach(async () => { beforeEach(async () => {
await adminSeeder(dbConnection) productCategoryParent = await simpleProductCategoryFactory(
dbConnection,
productCategoryParent = await simpleProductCategoryFactory(dbConnection, { {
name: "category parent", name: "category parent",
handle: "category-parent", handle: "category-parent",
}) }
)
productCategory = await simpleProductCategoryFactory(dbConnection, { productCategory = await simpleProductCategoryFactory(dbConnection, {
name: "category", name: "category",
@@ -64,42 +66,51 @@ describe("/admin/product-categories", () => {
parent_category: productCategoryParent, parent_category: productCategoryParent,
}) })
productCategoryChild = await simpleProductCategoryFactory(dbConnection, { productCategoryChild = await simpleProductCategoryFactory(
dbConnection,
{
name: "category child", name: "category child",
handle: "category-child", handle: "category-child",
parent_category: productCategory, parent_category: productCategory,
}) }
)
productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild2 = await simpleProductCategoryFactory(
dbConnection,
{
name: "category child 2", name: "category child 2",
handle: "category-child-2", handle: "category-child-2",
parent_category: productCategoryChild, parent_category: productCategoryChild,
}) }
}) )
afterEach(async () => {
const db = useDb()
return await db.teardown()
}) })
it("gets product category with children tree and parent", async () => { it("gets product category with children tree and parent", async () => {
const api = useApi() const path = breaking(
() => `/admin/product-categories/${productCategory.id}`,
const response = await api.get( () =>
`/admin/product-categories/${productCategory.id}`, `/admin/product-categories/${productCategory.id}?include_descendants_tree=true`
adminHeaders
) )
const response = await api.get(path, adminHeaders)
expect(response.data.product_category).toEqual( expect(response.data.product_category).toEqual(
expect.objectContaining({ expect.objectContaining({
id: productCategory.id, id: productCategory.id,
name: productCategory.name, name: productCategory.name,
handle: productCategory.handle, handle: productCategory.handle,
...breaking(
() => ({
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: productCategoryParent.id, id: productCategoryParent.id,
name: productCategoryParent.name, name: productCategoryParent.name,
handle: productCategoryParent.handle, handle: productCategoryParent.handle,
}), }),
}),
() => ({
parent_category_id: productCategoryParent.id,
})
),
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
@@ -124,12 +135,13 @@ describe("/admin/product-categories", () => {
describe("GET /admin/product-categories", () => { describe("GET /admin/product-categories", () => {
beforeEach(async () => { beforeEach(async () => {
await adminSeeder(dbConnection) productCategoryParent = await simpleProductCategoryFactory(
dbConnection,
productCategoryParent = await simpleProductCategoryFactory(dbConnection, { {
name: "Mens", name: "Mens",
rank: 0, rank: 0,
}) }
)
productCategory = await simpleProductCategoryFactory(dbConnection, { productCategory = await simpleProductCategoryFactory(dbConnection, {
name: "sweater", name: "sweater",
@@ -138,63 +150,82 @@ describe("/admin/product-categories", () => {
rank: 0, rank: 0,
}) })
productCategoryChild = await simpleProductCategoryFactory(dbConnection, { productCategoryChild = await simpleProductCategoryFactory(
dbConnection,
{
name: "cashmere", name: "cashmere",
parent_category: productCategory, parent_category: productCategory,
rank: 0, rank: 0,
}) }
)
productCategoryChild0 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild0 = await simpleProductCategoryFactory(
dbConnection,
{
name: "rank 2", name: "rank 2",
parent_category: productCategoryChild, parent_category: productCategoryChild,
rank: 2, rank: 2,
}) }
)
productCategoryChild1 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild1 = await simpleProductCategoryFactory(
dbConnection,
{
name: "rank 1", name: "rank 1",
parent_category: productCategoryChild, parent_category: productCategoryChild,
rank: 1, rank: 1,
}) }
)
productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild2 = await simpleProductCategoryFactory(
dbConnection,
{
name: "rank 0", name: "rank 0",
parent_category: productCategoryChild, parent_category: productCategoryChild,
rank: 0, rank: 0,
}) }
)
productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild3 = await simpleProductCategoryFactory(
dbConnection,
{
name: "rank 3", name: "rank 3",
parent_category: productCategoryChild, parent_category: productCategoryChild,
rank: 3, rank: 3,
}) }
}) )
afterEach(async () => {
const db = useDb()
return await db.teardown()
}) })
it("gets list of product category with immediate children and parents", async () => { it("gets list of product category with immediate children and parents", async () => {
const api = useApi() const path = breaking(
() => `/admin/product-categories?limit=7`,
const response = await api.get( () =>
`/admin/product-categories?limit=7`, `/admin/product-categories?include_descendants_tree=true&limit=7`
adminHeaders
) )
const response = await api.get(path, adminHeaders)
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.count).toEqual(7) expect(response.data.count).toEqual(7)
expect(response.data.offset).toEqual(0) expect(response.data.offset).toEqual(0)
expect(response.data.limit).toEqual(7) expect(response.data.limit).toEqual(7)
expect(response.data.product_categories).toEqual([ expect(response.data.product_categories).toEqual(
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
...breaking(
() => ({
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: productCategory.id, id: productCategory.id,
handle: productCategory.handle, handle: productCategory.handle,
rank: 0, rank: 0,
}), }),
}),
() => ({
parent_category_id: productCategory.id,
})
),
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild2.id, id: productCategoryChild2.id,
@@ -220,7 +251,10 @@ describe("/admin/product-categories", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: productCategoryParent.id, id: productCategoryParent.id,
parent_category: null, ...breaking(
() => ({ parent_category: null }),
() => ({})
),
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: productCategory.id, id: productCategory.id,
@@ -231,20 +265,34 @@ describe("/admin/product-categories", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild2.id, id: productCategoryChild2.id,
...breaking(
() => ({
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
}), }),
}),
() => ({
parent_category_id: productCategoryChild.id,
})
),
category_children: [], category_children: [],
rank: 0, rank: 0,
handle: productCategoryChild2.handle, handle: productCategoryChild2.handle,
}), }),
expect.objectContaining({ expect.objectContaining({
id: productCategory.id, id: productCategory.id,
...breaking(
() => ({
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: productCategoryParent.id, id: productCategoryParent.id,
rank: 0, rank: 0,
handle: productCategoryParent.handle, handle: productCategoryParent.handle,
}), }),
}),
() => ({
parent_category_id: productCategoryParent.id,
})
),
category_children: [ category_children: [
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
@@ -255,43 +303,63 @@ describe("/admin/product-categories", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild1.id, id: productCategoryChild1.id,
...breaking(
() => ({
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
handle: productCategoryChild.handle, handle: productCategoryChild.handle,
rank: 0, rank: 0,
}), }),
}),
() => ({
parent_category_id: productCategoryChild.id,
})
),
category_children: [], category_children: [],
handle: productCategoryChild1.handle, handle: productCategoryChild1.handle,
rank: 1, rank: 1,
}), }),
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild0.id, id: productCategoryChild0.id,
...breaking(
() => ({
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
handle: productCategoryChild.handle, handle: productCategoryChild.handle,
rank: 0, rank: 0,
}), }),
}),
() => ({
parent_category_id: productCategoryChild.id,
})
),
category_children: [], category_children: [],
handle: productCategoryChild0.handle, handle: productCategoryChild0.handle,
rank: 2, rank: 2,
}), }),
expect.objectContaining({ expect.objectContaining({
id: productCategoryChild3.id, id: productCategoryChild3.id,
...breaking(
() => ({
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: productCategoryChild.id, id: productCategoryChild.id,
handle: productCategoryChild.handle, handle: productCategoryChild.handle,
rank: 0, rank: 0,
}), }),
}),
() => ({
parent_category_id: productCategoryChild.id,
})
),
category_children: [], category_children: [],
handle: productCategoryChild3.handle, handle: productCategoryChild3.handle,
rank: 3, rank: 3,
}), }),
]) ])
)
}) })
it("filters based on whitelisted attributes of the data model", async () => { it("filters based on whitelisted attributes of the data model", async () => {
const api = useApi()
const response = await api.get( const response = await api.get(
`/admin/product-categories?is_internal=true&limit=7`, `/admin/product-categories?is_internal=true&limit=7`,
adminHeaders adminHeaders
@@ -299,12 +367,12 @@ describe("/admin/product-categories", () => {
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1) expect(response.data.count).toEqual(1)
expect(response.data.product_categories[0].id).toEqual(productCategory.id) expect(response.data.product_categories[0].id).toEqual(
productCategory.id
)
}) })
it("filters based on handle attribute of the data model", async () => { it("filters based on handle attribute of the data model", async () => {
const api = useApi()
const response = await api.get( const response = await api.get(
`/admin/product-categories?handle=${productCategory.handle}&limit=2`, `/admin/product-categories?handle=${productCategory.handle}&limit=2`,
adminHeaders adminHeaders
@@ -312,12 +380,12 @@ describe("/admin/product-categories", () => {
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1) expect(response.data.count).toEqual(1)
expect(response.data.product_categories[0].id).toEqual(productCategory.id) expect(response.data.product_categories[0].id).toEqual(
productCategory.id
)
}) })
it("filters based on free text on name and handle columns", async () => { it("filters based on free text on name and handle columns", async () => {
const api = useApi()
const response = await api.get( const response = await api.get(
`/admin/product-categories?q=men&limit=1`, `/admin/product-categories?q=men&limit=1`,
adminHeaders adminHeaders
@@ -331,8 +399,6 @@ describe("/admin/product-categories", () => {
}) })
it("filters based on parent category", async () => { it("filters based on parent category", async () => {
const api = useApi()
const response = await api.get( const response = await api.get(
`/admin/product-categories?parent_category_id=${productCategoryParent.id}&limit=7`, `/admin/product-categories?parent_category_id=${productCategoryParent.id}&limit=7`,
adminHeaders adminHeaders
@@ -340,10 +406,15 @@ describe("/admin/product-categories", () => {
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1) expect(response.data.count).toEqual(1)
expect(response.data.product_categories[0].id).toEqual(productCategory.id) expect(response.data.product_categories[0].id).toEqual(
productCategory.id
)
const nullCategoryResponse = await api const nullCategoryResponse = await api
.get(`/admin/product-categories?parent_category_id=null`, adminHeaders) .get(
`/admin/product-categories?parent_category_id=null`,
adminHeaders
)
.catch((e) => e) .catch((e) => e)
expect(nullCategoryResponse.status).toEqual(200) expect(nullCategoryResponse.status).toEqual(200)
@@ -354,8 +425,6 @@ describe("/admin/product-categories", () => {
}) })
it("adds all descendants to categories in a nested way", async () => { it("adds all descendants to categories in a nested way", async () => {
const api = useApi()
const response = await api.get( const response = await api.get(
`/admin/product-categories?parent_category_id=null&include_descendants_tree=true&limit=7`, `/admin/product-categories?parent_category_id=null&include_descendants_tree=true&limit=7`,
adminHeaders adminHeaders
@@ -413,41 +482,51 @@ describe("/admin/product-categories", () => {
describe("POST /admin/product-categories", () => { describe("POST /admin/product-categories", () => {
beforeEach(async () => { beforeEach(async () => {
await adminSeeder(dbConnection) productCategoryParent = await breaking(
async () =>
productCategoryParent = await simpleProductCategoryFactory(dbConnection, { await simpleProductCategoryFactory(dbConnection, {
name: "category parent",
handle: "category-parent",
}),
async () =>
await productModuleService.createCategory({
name: "category parent", name: "category parent",
handle: "category-parent", handle: "category-parent",
}) })
)
productCategory = await simpleProductCategoryFactory(dbConnection, { productCategory = await breaking(
async () =>
await simpleProductCategoryFactory(dbConnection, {
name: "category", name: "category",
handle: "category", handle: "category",
parent_category: productCategoryParent, parent_category: productCategoryParent,
}),
async () =>
await productModuleService.createCategory({
name: "category",
handle: "category",
parent_category_id: productCategoryParent.id,
}) })
}) )
afterEach(async () => {
const db = useDb()
return await db.teardown()
}) })
it("throws an error if required fields are missing", async () => { it("throws an error if required fields are missing", async () => {
const api = useApi()
const error = await api const error = await api
.post(`/admin/product-categories`, {}, adminHeaders) .post(`/admin/product-categories`, {}, adminHeaders)
.catch((e) => e) .catch((e) => e)
expect(error.response.status).toEqual(400) expect(error.response.status).toEqual(400)
expect(error.response.data.type).toEqual("invalid_data") expect(error.response.data.type).toEqual("invalid_data")
breaking(() => {
expect(error.response.data.message).toEqual( expect(error.response.data.message).toEqual(
"name should not be empty, name must be a string" "name should not be empty, name must be a string"
) )
}) })
})
// TODO: Remove in V2, unnecessary test
it("throws an error when description is not a string", async () => { it("throws an error when description is not a string", async () => {
const api = useApi()
const payload = { const payload = {
name: "test", name: "test",
handle: "test", handle: "test",
@@ -466,7 +545,6 @@ describe("/admin/product-categories", () => {
}) })
it("successfully creates a product category", async () => { it("successfully creates a product category", async () => {
const api = useApi()
const payload = { const payload = {
name: "test", name: "test",
handle: "test", handle: "test",
@@ -492,9 +570,16 @@ describe("/admin/product-categories", () => {
is_active: false, is_active: false,
created_at: expect.any(String), created_at: expect.any(String),
updated_at: expect.any(String), updated_at: expect.any(String),
...breaking(
() => ({
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: payload.parent_category_id, id: productCategory.id,
}), }),
}),
() => ({
parent_category_id: productCategory.id,
})
),
category_children: [], category_children: [],
rank: 0, rank: 0,
}), }),
@@ -503,7 +588,6 @@ describe("/admin/product-categories", () => {
}) })
it("successfully creates a product category with a rank", async () => { it("successfully creates a product category with a rank", async () => {
const api = useApi()
const payload = { const payload = {
name: "test", name: "test",
handle: "test", handle: "test",
@@ -527,9 +611,16 @@ describe("/admin/product-categories", () => {
is_active: false, is_active: false,
created_at: expect.any(String), created_at: expect.any(String),
updated_at: expect.any(String), updated_at: expect.any(String),
...breaking(
() => ({
parent_category: expect.objectContaining({ parent_category: expect.objectContaining({
id: productCategoryParent.id, id: productCategoryParent.id,
}), }),
}),
() => ({
parent_category_id: productCategoryParent.id,
})
),
category_children: [], category_children: [],
rank: 1, rank: 1,
}), }),
@@ -538,8 +629,6 @@ describe("/admin/product-categories", () => {
}) })
it("root parent returns children correctly on creating new category", async () => { it("root parent returns children correctly on creating new category", async () => {
const api = useApi()
const response = await api.post( const response = await api.post(
`/admin/product-categories`, `/admin/product-categories`,
{ {
@@ -550,11 +639,14 @@ describe("/admin/product-categories", () => {
) )
const lastDescendant = response.data.product_category const lastDescendant = response.data.product_category
const parentResponse = await api.get( const path = breaking(
`/admin/product-categories/${productCategoryParent.id}`, () => `/admin/product-categories/${productCategoryParent.id}`,
adminHeaders () =>
`/admin/product-categories/${productCategoryParent.id}?include_descendants_tree=true`
) )
const parentResponse = await api.get(path, adminHeaders)
expect(parentResponse.data.product_category).toEqual( expect(parentResponse.data.product_category).toEqual(
expect.objectContaining({ expect.objectContaining({
id: productCategoryParent.id, id: productCategoryParent.id,
@@ -576,12 +668,13 @@ describe("/admin/product-categories", () => {
describe("DELETE /admin/product-categories/:id", () => { describe("DELETE /admin/product-categories/:id", () => {
beforeEach(async () => { beforeEach(async () => {
await adminSeeder(dbConnection) productCategoryParent = await simpleProductCategoryFactory(
dbConnection,
productCategoryParent = await simpleProductCategoryFactory(dbConnection, { {
name: "category parent", name: "category parent",
handle: "category-parent", handle: "category-parent",
}) }
)
productCategory = await simpleProductCategoryFactory(dbConnection, { productCategory = await simpleProductCategoryFactory(dbConnection, {
name: "category", name: "category",
@@ -602,14 +695,7 @@ describe("/admin/product-categories", () => {
}) })
}) })
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("returns successfully with an invalid ID", async () => { it("returns successfully with an invalid ID", async () => {
const api = useApi()
const response = await api.delete( const response = await api.delete(
`/admin/product-categories/invalid-id`, `/admin/product-categories/invalid-id`,
adminHeaders adminHeaders
@@ -622,8 +708,6 @@ describe("/admin/product-categories", () => {
}) })
it("throws a not allowed error for a category with children", async () => { it("throws a not allowed error for a category with children", async () => {
const api = useApi()
const error = await api const error = await api
.delete( .delete(
`/admin/product-categories/${productCategoryParent.id}`, `/admin/product-categories/${productCategoryParent.id}`,
@@ -639,10 +723,11 @@ describe("/admin/product-categories", () => {
}) })
it("deletes a product category with no children successfully", async () => { it("deletes a product category with no children successfully", async () => {
const api = useApi()
const deleteResponse = await api const deleteResponse = await api
.delete(`/admin/product-categories/${productCategory.id}`, adminHeaders) .delete(
`/admin/product-categories/${productCategory.id}`,
adminHeaders
)
.catch((e) => e) .catch((e) => e)
expect(deleteResponse.status).toEqual(200) expect(deleteResponse.status).toEqual(200)
@@ -658,10 +743,11 @@ describe("/admin/product-categories", () => {
}) })
it("deleting a product category reorders siblings accurately", async () => { it("deleting a product category reorders siblings accurately", async () => {
const api = useApi()
const deleteResponse = await api const deleteResponse = await api
.delete(`/admin/product-categories/${productCategory.id}`, adminHeaders) .delete(
`/admin/product-categories/${productCategory.id}`,
adminHeaders
)
.catch((e) => e) .catch((e) => e)
expect(deleteResponse.status).toEqual(200) expect(deleteResponse.status).toEqual(200)
@@ -688,11 +774,12 @@ describe("/admin/product-categories", () => {
describe("POST /admin/product-categories/:id", () => { describe("POST /admin/product-categories/:id", () => {
beforeEach(async () => { beforeEach(async () => {
await adminSeeder(dbConnection) productCategoryParent = await simpleProductCategoryFactory(
dbConnection,
productCategoryParent = await simpleProductCategoryFactory(dbConnection, { {
name: "category parent", name: "category parent",
}) }
)
productCategory = await simpleProductCategoryFactory(dbConnection, { productCategory = await simpleProductCategoryFactory(dbConnection, {
name: "category-0", name: "category-0",
@@ -712,45 +799,53 @@ describe("/admin/product-categories", () => {
rank: 2, rank: 2,
}) })
productCategoryChild = await simpleProductCategoryFactory(dbConnection, { productCategoryChild = await simpleProductCategoryFactory(
dbConnection,
{
name: "category child", name: "category child",
parent_category: productCategory, parent_category: productCategory,
rank: 0, rank: 0,
}) }
)
productCategoryChild0 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild0 = await simpleProductCategoryFactory(
dbConnection,
{
name: "category child 0", name: "category child 0",
parent_category: productCategoryChild, parent_category: productCategoryChild,
rank: 0, rank: 0,
}) }
)
productCategoryChild1 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild1 = await simpleProductCategoryFactory(
dbConnection,
{
name: "category child 1", name: "category child 1",
parent_category: productCategoryChild, parent_category: productCategoryChild,
rank: 1, rank: 1,
}) }
)
productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild2 = await simpleProductCategoryFactory(
dbConnection,
{
name: "category child 2", name: "category child 2",
parent_category: productCategoryChild, parent_category: productCategoryChild,
rank: 2, rank: 2,
}) }
)
productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, { productCategoryChild3 = await simpleProductCategoryFactory(
dbConnection,
{
name: "category child 3", name: "category child 3",
parent_category: productCategoryChild, parent_category: productCategoryChild,
rank: 3, rank: 3,
}) }
}) )
afterEach(async () => {
const db = useDb()
return await db.teardown()
}) })
it("throws an error if invalid ID is sent", async () => { it("throws an error if invalid ID is sent", async () => {
const api = useApi()
const error = await api const error = await api
.post( .post(
`/admin/product-categories/not-found-id`, `/admin/product-categories/not-found-id`,
@@ -769,8 +864,6 @@ describe("/admin/product-categories", () => {
}) })
it("throws an error if rank is negative", async () => { it("throws an error if rank is negative", async () => {
const api = useApi()
const error = await api const error = await api
.post( .post(
`/admin/product-categories/not-found-id`, `/admin/product-categories/not-found-id`,
@@ -789,8 +882,6 @@ describe("/admin/product-categories", () => {
}) })
it("throws an error if invalid attribute is sent", async () => { it("throws an error if invalid attribute is sent", async () => {
const api = useApi()
const error = await api const error = await api
.post( .post(
`/admin/product-categories/${productCategory.id}`, `/admin/product-categories/${productCategory.id}`,
@@ -809,8 +900,6 @@ describe("/admin/product-categories", () => {
}) })
it("successfully updates a product category", async () => { it("successfully updates a product category", async () => {
const api = useApi()
const response = await api.post( const response = await api.post(
`/admin/product-categories/${productCategoryChild2.id}`, `/admin/product-categories/${productCategoryChild2.id}`,
{ {
@@ -844,8 +933,6 @@ describe("/admin/product-categories", () => {
}) })
it("updating properties other than rank should not change its rank", async () => { it("updating properties other than rank should not change its rank", async () => {
const api = useApi()
expect(productCategory.rank).toEqual(0) expect(productCategory.rank).toEqual(0)
const response = await api.post( const response = await api.post(
@@ -856,12 +943,12 @@ describe("/admin/product-categories", () => {
adminHeaders adminHeaders
) )
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.product_category.rank).toEqual(productCategory.rank) expect(response.data.product_category.rank).toEqual(
productCategory.rank
)
}) })
it("root parent returns children correctly on updating new category", async () => { it("root parent returns children correctly on updating new category", async () => {
const api = useApi()
const response = await api.post( const response = await api.post(
`/admin/product-categories/${productCategoryChild.id}`, `/admin/product-categories/${productCategoryChild.id}`,
{ {
@@ -906,8 +993,6 @@ describe("/admin/product-categories", () => {
}) })
it("when parent is updated, rank is updated to elements count + 1", async () => { it("when parent is updated, rank is updated to elements count + 1", async () => {
const api = useApi()
const response = await api.post( const response = await api.post(
`/admin/product-categories/${productCategoryChild1.id}`, `/admin/product-categories/${productCategoryChild1.id}`,
{ {
@@ -930,8 +1015,6 @@ describe("/admin/product-categories", () => {
}) })
it("when parent is updated with rank, rank accurately updated", async () => { it("when parent is updated with rank, rank accurately updated", async () => {
const api = useApi()
const response = await api.post( const response = await api.post(
`/admin/product-categories/${productCategoryChild1.id}`, `/admin/product-categories/${productCategoryChild1.id}`,
{ {
@@ -955,8 +1038,6 @@ describe("/admin/product-categories", () => {
}) })
it("when only rank is updated, rank should be updated", async () => { it("when only rank is updated, rank should be updated", async () => {
const api = useApi()
const response = await api.post( const response = await api.post(
`/admin/product-categories/${productCategoryChild1.id}`, `/admin/product-categories/${productCategoryChild1.id}`,
{ {
@@ -976,8 +1057,6 @@ describe("/admin/product-categories", () => {
}) })
it("when rank is greater than list count, rank is updated to updated to elements count + 1", async () => { it("when rank is greater than list count, rank is updated to updated to elements count + 1", async () => {
const api = useApi()
const response = await api.post( const response = await api.post(
`/admin/product-categories/${productCategoryChild1.id}`, `/admin/product-categories/${productCategoryChild1.id}`,
{ {
@@ -996,8 +1075,6 @@ describe("/admin/product-categories", () => {
}) })
it("when rank is updated, it accurately updates sibling ranks", async () => { it("when rank is updated, it accurately updates sibling ranks", async () => {
const api = useApi()
const response = await api.post( const response = await api.post(
`/admin/product-categories/${productCategoryChild2.id}`, `/admin/product-categories/${productCategoryChild2.id}`,
{ {
@@ -1039,21 +1116,13 @@ describe("/admin/product-categories", () => {
describe("POST /admin/product-categories/:id/products/batch", () => { describe("POST /admin/product-categories/:id/products/batch", () => {
beforeEach(async () => { beforeEach(async () => {
await adminSeeder(dbConnection)
productCategory = await simpleProductCategoryFactory(dbConnection, { productCategory = await simpleProductCategoryFactory(dbConnection, {
id: "test-category", id: "test-category",
name: "test category", name: "test category",
}) })
}) })
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should add products to a product category", async () => { it("should add products to a product category", async () => {
const api = useApi()
const testProduct1 = await simpleProductFactory(dbConnection, { const testProduct1 = await simpleProductFactory(dbConnection, {
id: "test-product-1", id: "test-product-1",
title: "test product 1", title: "test product 1",
@@ -1102,8 +1171,6 @@ describe("/admin/product-categories", () => {
}) })
it("throws error when product ID is invalid", async () => { it("throws error when product ID is invalid", async () => {
const api = useApi()
const payload = { const payload = {
product_ids: [{ id: "product-id-invalid" }], product_ids: [{ id: "product-id-invalid" }],
} }
@@ -1125,7 +1192,6 @@ describe("/admin/product-categories", () => {
}) })
it("throws error when category ID is invalid", async () => { it("throws error when category ID is invalid", async () => {
const api = useApi()
const payload = { product_ids: [] } const payload = { product_ids: [] }
const error = await api const error = await api
@@ -1144,7 +1210,6 @@ describe("/admin/product-categories", () => {
}) })
it("throws error trying to expand not allowed relations", async () => { it("throws error trying to expand not allowed relations", async () => {
const api = useApi()
const payload = { product_ids: [] } const payload = { product_ids: [] }
const error = await api const error = await api
@@ -1167,8 +1232,6 @@ describe("/admin/product-categories", () => {
let testProduct1, testProduct2 let testProduct1, testProduct2
beforeEach(async () => { beforeEach(async () => {
await adminSeeder(dbConnection)
testProduct1 = await simpleProductFactory(dbConnection, { testProduct1 = await simpleProductFactory(dbConnection, {
id: "test-product-1", id: "test-product-1",
title: "test product 1", title: "test product 1",
@@ -1186,14 +1249,7 @@ describe("/admin/product-categories", () => {
}) })
}) })
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should remove products from a product category", async () => { it("should remove products from a product category", async () => {
const api = useApi()
const payload = { const payload = {
product_ids: [{ id: testProduct2.id }], product_ids: [{ id: testProduct2.id }],
} }
@@ -1230,8 +1286,6 @@ describe("/admin/product-categories", () => {
}) })
it("throws error when product ID is invalid", async () => { it("throws error when product ID is invalid", async () => {
const api = useApi()
const payload = { const payload = {
product_ids: [{ id: "product-id-invalid" }], product_ids: [{ id: "product-id-invalid" }],
} }
@@ -1255,7 +1309,6 @@ describe("/admin/product-categories", () => {
}) })
it("throws error when category ID is invalid", async () => { it("throws error when category ID is invalid", async () => {
const api = useApi()
const payload = { product_ids: [] } const payload = { product_ids: [] }
const error = await api const error = await api
@@ -1276,7 +1329,6 @@ describe("/admin/product-categories", () => {
}) })
it("throws error trying to expand not allowed relations", async () => { it("throws error trying to expand not allowed relations", async () => {
const api = useApi()
const payload = { product_ids: [] } const payload = { product_ids: [] }
const error = await api const error = await api
@@ -1296,4 +1348,5 @@ describe("/admin/product-categories", () => {
}) })
}) })
}) })
},
}) })
+2 -1
View File
@@ -6,6 +6,7 @@ export * from "./customer-group"
export * from "./defaults" export * from "./defaults"
export * from "./definition" export * from "./definition"
export * from "./definitions" export * from "./definitions"
export * from "./file"
export * from "./fulfillment" export * from "./fulfillment"
export * as Handlers from "./handlers" export * as Handlers from "./handlers"
export * from "./inventory" export * from "./inventory"
@@ -15,6 +16,7 @@ export * from "./payment"
export * from "./price-list" export * from "./price-list"
export * from "./pricing" export * from "./pricing"
export * from "./product" export * from "./product"
export * from "./product-category"
export * from "./promotion" export * from "./promotion"
export * from "./reservation" export * from "./reservation"
export * from "./region" export * from "./region"
@@ -24,4 +26,3 @@ export * from "./stock-location"
export * from "./store" export * from "./store"
export * from "./tax" export * from "./tax"
export * from "./user" export * from "./user"
export * from "./file"
@@ -0,0 +1,2 @@
export * from "./steps"
export * from "./workflows"
@@ -0,0 +1,35 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
CreateProductCategoryDTO,
IProductModuleService,
} from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type CreateProductCategoryStepInput = {
product_category: CreateProductCategoryDTO
}
export const createProductCategoryStepId = "create-product-category"
export const createProductCategoryStep = createStep(
createProductCategoryStepId,
async (data: CreateProductCategoryStepInput, { container }) => {
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
const created = await service.createCategory(data.product_category)
return new StepResponse(created, created.id)
},
async (createdId, { container }) => {
if (!createdId) {
return
}
const service = container.resolve<IProductModuleService>(
ModuleRegistrationName.PRODUCT
)
await service.deleteCategory(createdId)
}
)
@@ -0,0 +1 @@
export * from "./create-product-category"
@@ -0,0 +1,16 @@
import { ProductCategoryWorkflow } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { createProductCategoryStep } from "../steps"
type WorkflowInputData =
ProductCategoryWorkflow.CreateProductCategoryWorkflowInput
export const createProductCategoryWorkflowId = "create-product-category"
export const createProductCategoryWorkflow = createWorkflow(
createProductCategoryWorkflowId,
(input: WorkflowData<WorkflowInputData>) => {
const category = createProductCategoryStep(input)
return category
}
)
@@ -0,0 +1 @@
export * from "./create-product-category"
@@ -1,8 +1,10 @@
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { authenticate } from "../../../utils/authenticate-middleware" import { authenticate } from "../../../utils/authenticate-middleware"
import { validateAndTransformBody } from "../../utils/validate-body"
import { validateAndTransformQuery } from "../../utils/validate-query" import { validateAndTransformQuery } from "../../utils/validate-query"
import * as QueryConfig from "./query-config" import * as QueryConfig from "./query-config"
import { import {
AdminCreateProductCategory,
AdminProductCategoriesParams, AdminProductCategoriesParams,
AdminProductCategoryParams, AdminProductCategoryParams,
} from "./validators" } from "./validators"
@@ -33,4 +35,15 @@ export const adminProductCategoryRoutesMiddlewares: MiddlewareRoute[] = [
), ),
], ],
}, },
{
method: ["POST"],
matcher: "/admin/product-categories",
middlewares: [
validateAndTransformBody(AdminCreateProductCategory),
validateAndTransformQuery(
AdminProductCategoryParams,
QueryConfig.retrieveProductCategoryConfig
),
],
},
] ]
@@ -11,10 +11,24 @@ export const defaults = [
"updated_at", "updated_at",
"metadata", "metadata",
"parent_category.id", "*category_children",
"parent_category.name", ]
"category_children.id",
"category_children.name", export const allowed = [
"id",
"name",
"description",
"handle",
"is_active",
"is_internal",
"rank",
"parent_category_id",
"created_at",
"updated_at",
"metadata",
"*parent_category",
"*category_children",
] ]
export const retrieveProductCategoryConfig = { export const retrieveProductCategoryConfig = {
@@ -1,4 +1,8 @@
import { AdminProductCategoryListResponse } from "@medusajs/types" import { createProductCategoryWorkflow } from "@medusajs/core-flows"
import {
AdminProductCategoryListResponse,
AdminProductCategoryResponse,
} from "@medusajs/types"
import { import {
ContainerRegistrationKeys, ContainerRegistrationKeys,
remoteQueryObjectFromString, remoteQueryObjectFromString,
@@ -7,7 +11,10 @@ import {
AuthenticatedMedusaRequest, AuthenticatedMedusaRequest,
MedusaResponse, MedusaResponse,
} from "../../../types/routing" } from "../../../types/routing"
import { AdminProductCategoriesParamsType } from "./validators" import {
AdminCreateProductCategoryType,
AdminProductCategoriesParamsType,
} from "./validators"
export const GET = async ( export const GET = async (
req: AuthenticatedMedusaRequest<AdminProductCategoriesParamsType>, req: AuthenticatedMedusaRequest<AdminProductCategoriesParamsType>,
@@ -33,3 +40,33 @@ export const GET = async (
limit: metadata.take, limit: metadata.take,
}) })
} }
export const POST = async (
req: AuthenticatedMedusaRequest<AdminCreateProductCategoryType>,
res: MedusaResponse<AdminProductCategoryResponse>
) => {
const { result, errors } = await createProductCategoryWorkflow(req.scope).run(
{
input: { product_category: req.validatedBody },
throwOnError: false,
}
)
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const queryObject = remoteQueryObjectFromString({
entryPoint: "product_category",
variables: {
filters: { id: result.id },
},
fields: req.remoteQueryConfig.fields,
})
const [product_category] = await remoteQuery(queryObject)
res.status(200).json({ product_category })
}
@@ -43,6 +43,14 @@ export const AdminProductCategoriesParams = createFindParams({
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()), (val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
z.boolean().optional() z.boolean().optional()
), ),
is_internal: z.preprocess(
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
z.boolean().optional()
),
is_active: z.preprocess(
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
z.boolean().optional()
),
created_at: createOperatorMap().optional(), created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(), updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(), deleted_at: createOperatorMap().optional(),
@@ -50,3 +58,19 @@ export const AdminProductCategoriesParams = createFindParams({
$or: z.lazy(() => AdminProductCategoriesParams.array()).optional(), $or: z.lazy(() => AdminProductCategoriesParams.array()).optional(),
}) })
) )
export const AdminCreateProductCategory = z
.object({
name: z.string(),
description: z.string().optional(),
handle: z.string().optional(),
is_internal: z.boolean().optional(),
is_active: z.boolean().optional(),
parent_category_id: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
})
.strict()
export type AdminCreateProductCategoryType = z.infer<
typeof AdminCreateProductCategory
>
@@ -1,10 +1,9 @@
import { Modules } from "@medusajs/modules-sdk" import { Modules } from "@medusajs/modules-sdk"
import { IProductModuleService, ProductTypes } from "@medusajs/types" import { IProductModuleService, ProductTypes } from "@medusajs/types"
import { Product, ProductCategory } from "@models" import { Product, ProductCategory } from "@models"
import { MockEventBusService } from "medusa-test-utils" import { MockEventBusService, SuiteOptions, moduleIntegrationTestRunner } from "medusa-test-utils"
import { createProductCategories } from "../../../__fixtures__/product-category" import { createProductCategories } from "../../../__fixtures__/product-category"
import { productCategoriesRankData } from "../../../__fixtures__/product-category/data" import { productCategoriesRankData } from "../../../__fixtures__/product-category/data"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -4,11 +4,7 @@ import {
ProductCategoryTransformOptions, ProductCategoryTransformOptions,
ProductTypes, ProductTypes,
} from "@medusajs/types" } from "@medusajs/types"
import { import { DALUtils, MedusaError, isDefined } from "@medusajs/utils"
DALUtils,
MedusaError,
isDefined
} from "@medusajs/utils"
import { import {
LoadStrategy, LoadStrategy,
FilterQuery as MikroFilterQuery, FilterQuery as MikroFilterQuery,
@@ -915,11 +915,21 @@ export default class ProductModuleService<
) )
} }
@InjectTransactionManager("baseRepository_") @InjectManager("baseRepository_")
async createCategory( async createCategory(
data: ProductTypes.CreateProductCategoryDTO, data: ProductTypes.CreateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductCategoryDTO> { ): Promise<ProductTypes.ProductCategoryDTO> {
const result = await this.createCategory_(data, sharedContext)
return await this.baseRepository_.serialize(result)
}
@InjectTransactionManager("baseRepository_")
async createCategory_(
data: ProductTypes.CreateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductCategory> {
const productCategory = await this.productCategoryService_.create( const productCategory = await this.productCategoryService_.create(
data, data,
sharedContext sharedContext
@@ -930,9 +940,7 @@ export default class ProductModuleService<
{ id: productCategory.id } { id: productCategory.id }
) )
return await this.baseRepository_.serialize(productCategory, { return productCategory
populate: true,
})
} }
@InjectTransactionManager("baseRepository_") @InjectTransactionManager("baseRepository_")
@@ -1115,8 +1123,10 @@ export default class ProductModuleService<
data: ProductTypes.CreateProductDTO[], data: ProductTypes.CreateProductDTO[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> { ): Promise<TProduct[]> {
const normalizedInput = await Promise.all( const normalizedInput = await promiseAll(
data.map((d) => this.normalizeCreateProductInput(d, sharedContext)) data.map(
async (d) => await this.normalizeCreateProductInput(d, sharedContext)
)
) )
const productData = await this.productService_.upsertWithReplace( const productData = await this.productService_.upsertWithReplace(
@@ -1171,8 +1181,10 @@ export default class ProductModuleService<
data: UpdateProductInput[], data: UpdateProductInput[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> { ): Promise<TProduct[]> {
const normalizedInput = await Promise.all( const normalizedInput = await promiseAll(
data.map((d) => this.normalizeUpdateProductInput(d, sharedContext)) data.map(
async (d) => await this.normalizeUpdateProductInput(d, sharedContext)
)
) )
const productData = await this.productService_.upsertWithReplace( const productData = await this.productService_.upsertWithReplace(
@@ -1258,7 +1270,8 @@ export default class ProductModuleService<
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.CreateProductDTO> { ): Promise<ProductTypes.CreateProductDTO> {
const productData = (await this.normalizeUpdateProductInput( const productData = (await this.normalizeUpdateProductInput(
product as UpdateProductInput product as UpdateProductInput,
sharedContext
)) as ProductTypes.CreateProductDTO )) as ProductTypes.CreateProductDTO
if (!productData.handle && productData.title) { if (!productData.handle && productData.title) {
+1 -4
View File
@@ -358,11 +358,8 @@ export interface CreateProductCategoryDTO {
rank?: number rank?: number
/** /**
* The ID of the parent product category, if it has any. * The ID of the parent product category, if it has any.
*
* @privateRemarks
* Shouldn't this be optional?
*/ */
parent_category_id: string | null parent_category_id?: string | null
/** /**
* Holds custom data in key-value pairs. * Holds custom data in key-value pairs.
*/ */
+8 -6
View File
@@ -1,10 +1,12 @@
export * as CartWorkflow from "./cart" export * as CartWorkflow from "./cart"
export * as CommonWorkflow from "./common" export * as CommonWorkflow from "./common"
export * as ProductWorkflow from "./product"
export * as InventoryWorkflow from "./inventory"
export * as PriceListWorkflow from "./price-list"
export * as UserWorkflow from "./user"
export * as RegionWorkflow from "./region"
export * as InviteWorkflow from "./invite"
export * as FulfillmentWorkflow from "./fulfillment" export * as FulfillmentWorkflow from "./fulfillment"
export * as InventoryWorkflow from "./inventory"
export * as InviteWorkflow from "./invite"
export * as PriceListWorkflow from "./price-list"
export * as ProductWorkflow from "./product"
export * as ProductCategoryWorkflow from "./product-category"
export * as RegionWorkflow from "./region"
export * as ReservationWorkflow from "./reservation" export * as ReservationWorkflow from "./reservation"
export * as UserWorkflow from "./user"
@@ -0,0 +1,5 @@
import { CreateProductCategoryDTO } from "../../product"
export interface CreateProductCategoryWorkflowInput {
product_category: CreateProductCategoryDTO
}