diff --git a/integration-tests/api/__tests__/admin/product-category.ts b/integration-tests/api/__tests__/admin/product-category.ts index 51d527eea9..b504fb0726 100644 --- a/integration-tests/api/__tests__/admin/product-category.ts +++ b/integration-tests/api/__tests__/admin/product-category.ts @@ -1,1299 +1,1352 @@ -import path from "path" -import { Product, ProductCategory } from "@medusajs/medusa" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IProductModuleService } from "@medusajs/types" +import { medusaIntegrationTestRunner } from "medusa-test-utils" import { In } from "typeorm" - -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 { breaking } from "../../../helpers/breaking" import { - simpleProductCategoryFactory, - simpleProductFactory, -} from "../../../factories" + adminHeaders, + createAdminUser, +} from "../../../helpers/create-admin-user" jest.setTimeout(30000) -const adminHeaders = { - headers: { - "x-medusa-access-token": "test_token", +let { simpleProductCategoryFactory, simpleProductFactory } = {} +let { Product } = {} + +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 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 + let productModuleService: IProductModuleService - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")) - const [process, connection] = await startServerWithEnvironment({ - cwd, - env: { MEDUSA_FF_PRODUCT_CATEGORIES: true }, + beforeAll(() => { + ;({ + simpleProductCategoryFactory, + simpleProductFactory, + } = require("../../../factories")) + ;({ Product } = require("@medusajs/medusa")) }) - 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) + appContainer = getContainer() - 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 + productModuleService = appContainer.resolve( + ModuleRegistrationName.PRODUCT ) - 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: [ + await createAdminUser(dbConnection, adminHeaders, appContainer) + }) + + describe("GET /admin/product-categories/:id", () => { + beforeEach(async () => { + 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, + } + ) + }) + + it("gets product category with children tree and parent", async () => { + const path = breaking( + () => `/admin/product-categories/${productCategory.id}`, + () => + `/admin/product-categories/${productCategory.id}?include_descendants_tree=true` + ) + + const response = await api.get(path, adminHeaders) + + expect(response.data.product_category).toEqual( + expect.objectContaining({ + id: productCategory.id, + name: productCategory.name, + handle: productCategory.handle, + ...breaking( + () => ({ + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + name: productCategoryParent.name, + handle: productCategoryParent.handle, + }), + }), + () => ({ + parent_category_id: productCategoryParent.id, + }) + ), + 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) + }) + }) + + describe("GET /admin/product-categories", () => { + beforeEach(async () => { + productCategoryParent = await simpleProductCategoryFactory( + dbConnection, + { + name: "Mens", + rank: 0, + } + ) + + productCategory = await simpleProductCategoryFactory(dbConnection, { + name: "sweater", + parent_category: productCategoryParent, + is_internal: true, + rank: 0, + }) + + productCategoryChild = await simpleProductCategoryFactory( + dbConnection, + { + name: "cashmere", + parent_category: productCategory, + rank: 0, + } + ) + + productCategoryChild0 = await simpleProductCategoryFactory( + dbConnection, + { + name: "rank 2", + parent_category: productCategoryChild, + rank: 2, + } + ) + + productCategoryChild1 = await simpleProductCategoryFactory( + dbConnection, + { + name: "rank 1", + parent_category: productCategoryChild, + rank: 1, + } + ) + + productCategoryChild2 = await simpleProductCategoryFactory( + dbConnection, + { + name: "rank 0", + parent_category: productCategoryChild, + rank: 0, + } + ) + + productCategoryChild3 = await simpleProductCategoryFactory( + dbConnection, + { + name: "rank 3", + parent_category: productCategoryChild, + rank: 3, + } + ) + }) + + it("gets list of product category with immediate children and parents", async () => { + const path = breaking( + () => `/admin/product-categories?limit=7`, + () => + `/admin/product-categories?include_descendants_tree=true&limit=7` + ) + + const response = await api.get(path, adminHeaders) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(7) + expect(response.data.offset).toEqual(0) + expect(response.data.limit).toEqual(7) + + expect(response.data.product_categories).toEqual( + expect.arrayContaining([ expect.objectContaining({ id: productCategoryChild.id, - name: productCategoryChild.name, - handle: productCategoryChild.handle, + ...breaking( + () => ({ + parent_category: expect.objectContaining({ + id: productCategory.id, + handle: productCategory.handle, + rank: 0, + }), + }), + () => ({ + parent_category_id: productCategory.id, + }) + ), category_children: [ expect.objectContaining({ id: productCategoryChild2.id, - name: productCategoryChild2.name, handle: productCategoryChild2.handle, - category_children: [], + rank: 0, }), - ], - }), - ], - }) - ) - - expect(response.status).toEqual(200) - }) - }) - - describe("GET /admin/product-categories", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - - productCategoryParent = await simpleProductCategoryFactory(dbConnection, { - name: "Mens", - rank: 0, - }) - - productCategory = await simpleProductCategoryFactory(dbConnection, { - name: "sweater", - parent_category: productCategoryParent, - is_internal: true, - rank: 0, - }) - - productCategoryChild = await simpleProductCategoryFactory(dbConnection, { - name: "cashmere", - parent_category: productCategory, - rank: 0, - }) - - productCategoryChild0 = await simpleProductCategoryFactory(dbConnection, { - name: "rank 2", - parent_category: productCategoryChild, - rank: 2, - }) - - productCategoryChild1 = await simpleProductCategoryFactory(dbConnection, { - name: "rank 1", - parent_category: productCategoryChild, - rank: 1, - }) - - productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { - name: "rank 0", - parent_category: productCategoryChild, - rank: 0, - }) - - productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, { - name: "rank 3", - parent_category: productCategoryChild, - rank: 3, - }) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("gets list of product category with immediate children and parents", async () => { - const api = useApi() - - const response = await api.get( - `/admin/product-categories?limit=7`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.count).toEqual(7) - expect(response.data.offset).toEqual(0) - expect(response.data.limit).toEqual(7) - - expect(response.data.product_categories).toEqual([ - expect.objectContaining({ - id: productCategoryChild.id, - parent_category: expect.objectContaining({ - id: productCategory.id, - handle: productCategory.handle, - rank: 0, - }), - category_children: [ - expect.objectContaining({ - id: productCategoryChild2.id, - handle: productCategoryChild2.handle, - rank: 0, - }), - expect.objectContaining({ - id: productCategoryChild1.id, - handle: productCategoryChild1.handle, - rank: 1, - }), - expect.objectContaining({ - id: productCategoryChild0.id, - handle: productCategoryChild0.handle, - rank: 2, - }), - expect.objectContaining({ - id: productCategoryChild3.id, - handle: productCategoryChild3.handle, - rank: 3, - }), - ], - }), - expect.objectContaining({ - id: productCategoryParent.id, - parent_category: null, - category_children: [ - expect.objectContaining({ - id: productCategory.id, - handle: productCategory.handle, - rank: 0, - }), - ], - }), - expect.objectContaining({ - id: productCategoryChild2.id, - parent_category: expect.objectContaining({ - id: productCategoryChild.id, - }), - category_children: [], - rank: 0, - handle: productCategoryChild2.handle, - }), - expect.objectContaining({ - id: productCategory.id, - parent_category: expect.objectContaining({ - id: productCategoryParent.id, - rank: 0, - handle: productCategoryParent.handle, - }), - category_children: [ - expect.objectContaining({ - id: productCategoryChild.id, - handle: productCategoryChild.handle, - rank: 0, - }), - ], - }), - expect.objectContaining({ - id: productCategoryChild1.id, - parent_category: expect.objectContaining({ - id: productCategoryChild.id, - handle: productCategoryChild.handle, - rank: 0, - }), - category_children: [], - handle: productCategoryChild1.handle, - rank: 1, - }), - expect.objectContaining({ - id: productCategoryChild0.id, - parent_category: expect.objectContaining({ - id: productCategoryChild.id, - handle: productCategoryChild.handle, - rank: 0, - }), - category_children: [], - handle: productCategoryChild0.handle, - rank: 2, - }), - expect.objectContaining({ - id: productCategoryChild3.id, - parent_category: expect.objectContaining({ - id: productCategoryChild.id, - handle: productCategoryChild.handle, - rank: 0, - }), - category_children: [], - handle: productCategoryChild3.handle, - rank: 3, - }), - ]) - }) - - it("filters based on whitelisted attributes of the data model", async () => { - const api = useApi() - - const response = await api.get( - `/admin/product-categories?is_internal=true&limit=7`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.count).toEqual(1) - expect(response.data.product_categories[0].id).toEqual(productCategory.id) - }) - - it("filters based on handle attribute of the data model", async () => { - const api = useApi() - - const response = await api.get( - `/admin/product-categories?handle=${productCategory.handle}&limit=2`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.count).toEqual(1) - expect(response.data.product_categories[0].id).toEqual(productCategory.id) - }) - - it("filters based on free text on name and handle columns", async () => { - const api = useApi() - - const response = await api.get( - `/admin/product-categories?q=men&limit=1`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.count).toEqual(1) - expect(response.data.product_categories[0].id).toEqual( - productCategoryParent.id - ) - }) - - it("filters based on parent category", async () => { - const api = useApi() - - const response = await api.get( - `/admin/product-categories?parent_category_id=${productCategoryParent.id}&limit=7`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.count).toEqual(1) - expect(response.data.product_categories[0].id).toEqual(productCategory.id) - - const nullCategoryResponse = await api - .get(`/admin/product-categories?parent_category_id=null`, adminHeaders) - .catch((e) => e) - - expect(nullCategoryResponse.status).toEqual(200) - expect(nullCategoryResponse.data.count).toEqual(1) - expect(nullCategoryResponse.data.product_categories[0].id).toEqual( - productCategoryParent.id - ) - }) - - it("adds all descendants to categories in a nested way", async () => { - const api = useApi() - - const response = await api.get( - `/admin/product-categories?parent_category_id=null&include_descendants_tree=true&limit=7`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.count).toEqual(1) - expect(response.data.product_categories).toEqual([ - expect.objectContaining({ - id: productCategoryParent.id, - rank: 0, - handle: productCategoryParent.handle, - category_children: [ - expect.objectContaining({ - id: productCategory.id, - rank: 0, - handle: productCategory.handle, - category_children: [ expect.objectContaining({ - id: productCategoryChild.id, - category_children: [ - expect.objectContaining({ - id: productCategoryChild2.id, - category_children: [], - handle: productCategoryChild2.handle, - rank: 0, - }), - expect.objectContaining({ - id: productCategoryChild1.id, - category_children: [], - handle: productCategoryChild1.handle, - rank: 1, - }), - expect.objectContaining({ - id: productCategoryChild0.id, - category_children: [], - handle: productCategoryChild0.handle, - rank: 2, - }), - expect.objectContaining({ - id: productCategoryChild3.id, - category_children: [], - handle: productCategoryChild3.handle, - rank: 3, - }), - ], + id: productCategoryChild1.id, + handle: productCategoryChild1.handle, + rank: 1, + }), + expect.objectContaining({ + id: productCategoryChild0.id, + handle: productCategoryChild0.handle, + rank: 2, + }), + expect.objectContaining({ + id: productCategoryChild3.id, + handle: productCategoryChild3.handle, + rank: 3, }), ], }), - ], - }), - ]) - }) - }) - - describe("POST /admin/product-categories", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - - productCategoryParent = await simpleProductCategoryFactory(dbConnection, { - name: "category parent", - handle: "category-parent", - }) - - productCategory = await simpleProductCategoryFactory(dbConnection, { - name: "category", - handle: "category", - parent_category: productCategoryParent, - }) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("throws an error if required fields are missing", async () => { - const api = useApi() - - const error = await api - .post(`/admin/product-categories`, {}, adminHeaders) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data.type).toEqual("invalid_data") - expect(error.response.data.message).toEqual( - "name should not be empty, name must be a string" - ) - }) - - it("throws an error when description is not a string", async () => { - const api = useApi() - const payload = { - name: "test", - handle: "test", - description: null, - } - - const error = await api - .post(`/admin/product-categories`, payload, adminHeaders) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data.type).toEqual("invalid_data") - expect(error.response.data.message).toEqual( - "description must be a string" - ) - }) - - it("successfully creates a product category", async () => { - const api = useApi() - const payload = { - name: "test", - handle: "test", - is_internal: true, - parent_category_id: productCategory.id, - description: "test", - } - - const response = await api.post( - `/admin/product-categories`, - payload, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data).toEqual( - expect.objectContaining({ - product_category: expect.objectContaining({ - name: payload.name, - description: payload.description, - handle: payload.handle, - is_internal: payload.is_internal, - is_active: false, - created_at: expect.any(String), - updated_at: expect.any(String), - parent_category: expect.objectContaining({ - id: payload.parent_category_id, - }), - category_children: [], - rank: 0, - }), - }) - ) - }) - - it("successfully creates a product category with a rank", async () => { - const api = useApi() - const payload = { - name: "test", - handle: "test", - is_internal: true, - parent_category_id: productCategoryParent.id, - } - - const response = await api.post( - `/admin/product-categories`, - payload, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data).toEqual( - expect.objectContaining({ - product_category: expect.objectContaining({ - name: payload.name, - handle: payload.handle, - is_internal: payload.is_internal, - is_active: false, - created_at: expect.any(String), - updated_at: expect.any(String), - parent_category: expect.objectContaining({ + expect.objectContaining({ id: productCategoryParent.id, - }), - category_children: [], - rank: 1, - }), - }) - ) - }) - - it("root parent returns children correctly on creating new category", async () => { - const api = useApi() - - const response = await api.post( - `/admin/product-categories`, - { - name: "last descendant", - parent_category_id: productCategory.id, - }, - adminHeaders - ) - const lastDescendant = response.data.product_category - - const parentResponse = await api.get( - `/admin/product-categories/${productCategoryParent.id}`, - adminHeaders - ) - - expect(parentResponse.data.product_category).toEqual( - expect.objectContaining({ - id: productCategoryParent.id, - category_children: [ - expect.objectContaining({ - id: productCategory.id, + ...breaking( + () => ({ parent_category: null }), + () => ({}) + ), category_children: [ expect.objectContaining({ - id: lastDescendant.id, - category_children: [], - }), - ], - }), - ], - }) - ) - }) - }) - - describe("DELETE /admin/product-categories/:id", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - - productCategoryParent = await simpleProductCategoryFactory(dbConnection, { - name: "category parent", - handle: "category-parent", - }) - - productCategory = await simpleProductCategoryFactory(dbConnection, { - name: "category", - handle: "category", - parent_category: productCategoryParent, - }) - - productCategory1 = await simpleProductCategoryFactory(dbConnection, { - name: "category-1", - parent_category: productCategoryParent, - rank: 1, - }) - - productCategory2 = await simpleProductCategoryFactory(dbConnection, { - name: "category-2", - parent_category: productCategoryParent, - rank: 2, - }) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("returns successfully with an invalid ID", async () => { - const api = useApi() - - const response = await api.delete( - `/admin/product-categories/invalid-id`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.id).toEqual("invalid-id") - expect(response.data.deleted).toBeTruthy() - expect(response.data.object).toEqual("product-category") - }) - - it("throws a not allowed error for a category with children", async () => { - const api = useApi() - - const error = await api - .delete( - `/admin/product-categories/${productCategoryParent.id}`, - adminHeaders - ) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data.type).toEqual("not_allowed") - expect(error.response.data.message).toEqual( - `Deleting ProductCategory (${productCategoryParent.id}) with category children is not allowed` - ) - }) - - it("deletes a product category with no children successfully", async () => { - const api = useApi() - - const deleteResponse = await api - .delete(`/admin/product-categories/${productCategory.id}`, adminHeaders) - .catch((e) => e) - - expect(deleteResponse.status).toEqual(200) - expect(deleteResponse.data.id).toEqual(productCategory.id) - expect(deleteResponse.data.deleted).toBeTruthy() - expect(deleteResponse.data.object).toEqual("product-category") - - const errorFetchingDeleted = await api - .get(`/admin/product-categories/${productCategory.id}`, adminHeaders) - .catch((e) => e) - - expect(errorFetchingDeleted.response.status).toEqual(404) - }) - - it("deleting a product category reorders siblings accurately", async () => { - const api = useApi() - - const deleteResponse = await api - .delete(`/admin/product-categories/${productCategory.id}`, adminHeaders) - .catch((e) => e) - - expect(deleteResponse.status).toEqual(200) - - const siblingsResponse = await api - .get( - `/admin/product-categories?parent_category_id=${productCategoryParent.id}`, - adminHeaders - ) - .catch((e) => e) - - expect(siblingsResponse.data.product_categories).toEqual([ - expect.objectContaining({ - id: productCategory1.id, - rank: 0, - }), - expect.objectContaining({ - id: productCategory2.id, - rank: 1, - }), - ]) - }) - }) - - describe("POST /admin/product-categories/:id", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - - productCategoryParent = await simpleProductCategoryFactory(dbConnection, { - name: "category parent", - }) - - productCategory = await simpleProductCategoryFactory(dbConnection, { - name: "category-0", - parent_category: productCategoryParent, - rank: 0, - }) - - productCategory1 = await simpleProductCategoryFactory(dbConnection, { - name: "category-1", - parent_category: productCategoryParent, - rank: 1, - }) - - productCategory2 = await simpleProductCategoryFactory(dbConnection, { - name: "category-2", - parent_category: productCategoryParent, - rank: 2, - }) - - productCategoryChild = await simpleProductCategoryFactory(dbConnection, { - name: "category child", - parent_category: productCategory, - rank: 0, - }) - - productCategoryChild0 = await simpleProductCategoryFactory(dbConnection, { - name: "category child 0", - parent_category: productCategoryChild, - rank: 0, - }) - - productCategoryChild1 = await simpleProductCategoryFactory(dbConnection, { - name: "category child 1", - parent_category: productCategoryChild, - rank: 1, - }) - - productCategoryChild2 = await simpleProductCategoryFactory(dbConnection, { - name: "category child 2", - parent_category: productCategoryChild, - rank: 2, - }) - - productCategoryChild3 = await simpleProductCategoryFactory(dbConnection, { - name: "category child 3", - parent_category: productCategoryChild, - rank: 3, - }) - }) - - afterEach(async () => { - const db = useDb() - return await db.teardown() - }) - - it("throws an error if invalid ID is sent", async () => { - const api = useApi() - - const error = await api - .post( - `/admin/product-categories/not-found-id`, - { - name: "testing", - }, - adminHeaders - ) - .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: not-found-id was not found" - ) - }) - - it("throws an error if rank is negative", async () => { - const api = useApi() - - const error = await api - .post( - `/admin/product-categories/not-found-id`, - { - rank: -1, - }, - adminHeaders - ) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data.type).toEqual("invalid_data") - expect(error.response.data.message).toEqual( - "rank must not be less than 0" - ) - }) - - it("throws an error if invalid attribute is sent", async () => { - const api = useApi() - - const error = await api - .post( - `/admin/product-categories/${productCategory.id}`, - { - invalid_property: "string", - }, - adminHeaders - ) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data.type).toEqual("invalid_data") - expect(error.response.data.message).toEqual( - "property invalid_property should not exist" - ) - }) - - it("successfully updates a product category", async () => { - const api = useApi() - - const response = await api.post( - `/admin/product-categories/${productCategoryChild2.id}`, - { - name: "test", - handle: "test", - is_internal: true, - is_active: true, - parent_category_id: productCategory.id, - }, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data).toEqual( - expect.objectContaining({ - product_category: expect.objectContaining({ - name: "test", - handle: "test", - is_internal: true, - is_active: true, - created_at: expect.any(String), - updated_at: expect.any(String), - parent_category: expect.objectContaining({ - id: productCategory.id, - }), - category_children: [], - rank: 1, - }), - }) - ) - }) - - it("updating properties other than rank should not change its rank", async () => { - const api = useApi() - - expect(productCategory.rank).toEqual(0) - - const response = await api.post( - `/admin/product-categories/${productCategory.id}`, - { - name: "different-name", - }, - adminHeaders - ) - expect(response.status).toEqual(200) - expect(response.data.product_category.rank).toEqual(productCategory.rank) - }) - - it("root parent returns children correctly on updating new category", async () => { - const api = useApi() - - const response = await api.post( - `/admin/product-categories/${productCategoryChild.id}`, - { - parent_category_id: productCategory.id, - }, - adminHeaders - ) - const lastDescendant = response.data.product_category - - const parentResponse = await api.get( - `/admin/product-categories/${productCategoryParent.id}`, - adminHeaders - ) - - expect(parentResponse.data.product_category).toEqual( - expect.objectContaining({ - id: productCategoryParent.id, - category_children: [ - expect.objectContaining({ - id: productCategory.id, - rank: 0, - category_children: [ - expect.objectContaining({ - id: productCategoryChild.id, + id: productCategory.id, + handle: productCategory.handle, rank: 0, }), ], }), - expect.objectContaining({ - id: productCategory1.id, - category_children: [], - rank: 1, - }), - expect.objectContaining({ - id: productCategory2.id, - category_children: [], - rank: 2, - }), - ], - }) - ) - }) - - it("when parent is updated, rank is updated to elements count + 1", async () => { - const api = useApi() - - const response = await api.post( - `/admin/product-categories/${productCategoryChild1.id}`, - { - parent_category_id: productCategoryParent.id, - }, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data).toEqual( - expect.objectContaining({ - product_category: expect.objectContaining({ - parent_category: expect.objectContaining({ - id: productCategoryParent.id, - }), - rank: 3, - }), - }) - ) - }) - - it("when parent is updated with rank, rank accurately updated", async () => { - const api = useApi() - - const response = await api.post( - `/admin/product-categories/${productCategoryChild1.id}`, - { - parent_category_id: productCategoryParent.id, - rank: 0, - }, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data).toEqual( - expect.objectContaining({ - product_category: expect.objectContaining({ - parent_category: expect.objectContaining({ - id: productCategoryParent.id, - }), - rank: 0, - }), - }) - ) - }) - - it("when only rank is updated, rank should be updated", async () => { - const api = useApi() - - const response = await api.post( - `/admin/product-categories/${productCategoryChild1.id}`, - { - rank: 0, - }, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data).toEqual( - expect.objectContaining({ - product_category: expect.objectContaining({ - rank: 0, - }), - }) - ) - }) - - 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( - `/admin/product-categories/${productCategoryChild1.id}`, - { - rank: 99, - }, - adminHeaders - ) - - expect(response.data).toEqual( - expect.objectContaining({ - product_category: expect.objectContaining({ - rank: 3, - }), - }) - ) - }) - - it("when rank is updated, it accurately updates sibling ranks", async () => { - const api = useApi() - - const response = await api.post( - `/admin/product-categories/${productCategoryChild2.id}`, - { - rank: 0, - }, - adminHeaders - ) - - const parentResponse = await api.get( - `/admin/product-categories/${productCategoryChild2.parent_category_id}`, - adminHeaders - ) - - expect(parentResponse.data.product_category).toEqual( - expect.objectContaining({ - id: productCategoryChild2.parent_category_id, - category_children: [ expect.objectContaining({ id: productCategoryChild2.id, + ...breaking( + () => ({ + parent_category: expect.objectContaining({ + id: productCategoryChild.id, + }), + }), + () => ({ + parent_category_id: productCategoryChild.id, + }) + ), + category_children: [], rank: 0, + handle: productCategoryChild2.handle, }), expect.objectContaining({ - id: productCategoryChild0.id, - rank: 1, + id: productCategory.id, + ...breaking( + () => ({ + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + rank: 0, + handle: productCategoryParent.handle, + }), + }), + () => ({ + parent_category_id: productCategoryParent.id, + }) + ), + category_children: [ + expect.objectContaining({ + id: productCategoryChild.id, + handle: productCategoryChild.handle, + rank: 0, + }), + ], }), expect.objectContaining({ id: productCategoryChild1.id, + ...breaking( + () => ({ + parent_category: expect.objectContaining({ + id: productCategoryChild.id, + handle: productCategoryChild.handle, + rank: 0, + }), + }), + () => ({ + parent_category_id: productCategoryChild.id, + }) + ), + category_children: [], + handle: productCategoryChild1.handle, + rank: 1, + }), + expect.objectContaining({ + id: productCategoryChild0.id, + ...breaking( + () => ({ + parent_category: expect.objectContaining({ + id: productCategoryChild.id, + handle: productCategoryChild.handle, + rank: 0, + }), + }), + () => ({ + parent_category_id: productCategoryChild.id, + }) + ), + category_children: [], + handle: productCategoryChild0.handle, rank: 2, }), expect.objectContaining({ id: productCategoryChild3.id, + ...breaking( + () => ({ + parent_category: expect.objectContaining({ + id: productCategoryChild.id, + handle: productCategoryChild.handle, + rank: 0, + }), + }), + () => ({ + parent_category_id: productCategoryChild.id, + }) + ), + category_children: [], + handle: productCategoryChild3.handle, rank: 3, }), - ], + ]) + ) + }) + + it("filters based on whitelisted attributes of the data model", async () => { + const response = await api.get( + `/admin/product-categories?is_internal=true&limit=7`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.product_categories[0].id).toEqual( + productCategory.id + ) + }) + + it("filters based on handle attribute of the data model", async () => { + const response = await api.get( + `/admin/product-categories?handle=${productCategory.handle}&limit=2`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.product_categories[0].id).toEqual( + productCategory.id + ) + }) + + it("filters based on free text on name and handle columns", async () => { + const response = await api.get( + `/admin/product-categories?q=men&limit=1`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.product_categories[0].id).toEqual( + productCategoryParent.id + ) + }) + + it("filters based on parent category", async () => { + const response = await api.get( + `/admin/product-categories?parent_category_id=${productCategoryParent.id}&limit=7`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.product_categories[0].id).toEqual( + productCategory.id + ) + + const nullCategoryResponse = await api + .get( + `/admin/product-categories?parent_category_id=null`, + adminHeaders + ) + .catch((e) => e) + + expect(nullCategoryResponse.status).toEqual(200) + expect(nullCategoryResponse.data.count).toEqual(1) + expect(nullCategoryResponse.data.product_categories[0].id).toEqual( + productCategoryParent.id + ) + }) + + it("adds all descendants to categories in a nested way", async () => { + const response = await api.get( + `/admin/product-categories?parent_category_id=null&include_descendants_tree=true&limit=7`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.product_categories).toEqual([ + expect.objectContaining({ + id: productCategoryParent.id, + rank: 0, + handle: productCategoryParent.handle, + category_children: [ + expect.objectContaining({ + id: productCategory.id, + rank: 0, + handle: productCategory.handle, + category_children: [ + expect.objectContaining({ + id: productCategoryChild.id, + category_children: [ + expect.objectContaining({ + id: productCategoryChild2.id, + category_children: [], + handle: productCategoryChild2.handle, + rank: 0, + }), + expect.objectContaining({ + id: productCategoryChild1.id, + category_children: [], + handle: productCategoryChild1.handle, + rank: 1, + }), + expect.objectContaining({ + id: productCategoryChild0.id, + category_children: [], + handle: productCategoryChild0.handle, + rank: 2, + }), + expect.objectContaining({ + id: productCategoryChild3.id, + category_children: [], + handle: productCategoryChild3.handle, + rank: 3, + }), + ], + }), + ], + }), + ], + }), + ]) + }) + }) + + describe("POST /admin/product-categories", () => { + beforeEach(async () => { + productCategoryParent = await breaking( + async () => + await simpleProductCategoryFactory(dbConnection, { + name: "category parent", + handle: "category-parent", + }), + async () => + await productModuleService.createCategory({ + name: "category parent", + handle: "category-parent", + }) + ) + + productCategory = await breaking( + async () => + await simpleProductCategoryFactory(dbConnection, { + name: "category", + handle: "category", + parent_category: productCategoryParent, + }), + async () => + await productModuleService.createCategory({ + name: "category", + handle: "category", + parent_category_id: productCategoryParent.id, + }) + ) + }) + + it("throws an error if required fields are missing", async () => { + const error = await api + .post(`/admin/product-categories`, {}, adminHeaders) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual("invalid_data") + breaking(() => { + expect(error.response.data.message).toEqual( + "name should not be empty, name must be a string" + ) }) - ) - }) - }) - - describe("POST /admin/product-categories/:id/products/batch", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - - productCategory = await simpleProductCategoryFactory(dbConnection, { - id: "test-category", - name: "test category", - }) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("should add products to a product category", async () => { - const api = useApi() - const testProduct1 = await simpleProductFactory(dbConnection, { - id: "test-product-1", - title: "test product 1", }) - const testProduct2 = await simpleProductFactory(dbConnection, { - id: "test-product-2", - title: "test product 2", - }) - - const payload = { - product_ids: [{ id: testProduct1.id }, { id: testProduct2.id }], - } - - const response = await api.post( - `/admin/product-categories/${productCategory.id}/products/batch`, - payload, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.product_category).toEqual( - expect.objectContaining({ - id: productCategory.id, - created_at: expect.any(String), - updated_at: expect.any(String), - }) - ) - - const products = await dbConnection.manager.find(Product, { - where: { id: In([testProduct1.id, testProduct2.id]) }, - relations: ["categories"], - }) - - expect(products[0].categories).toEqual([ - expect.objectContaining({ - id: productCategory.id, - }), - ]) - - expect(products[1].categories).toEqual([ - expect.objectContaining({ - id: productCategory.id, - }), - ]) - }) - - it("throws error when product ID is invalid", async () => { - const api = useApi() - - const payload = { - product_ids: [{ id: "product-id-invalid" }], - } - - const error = await api - .post( - `/admin/product-categories/${productCategory.id}/products/batch`, - payload, - adminHeaders - ) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data).toEqual({ - errors: ["Products product-id-invalid do not exist"], - message: - "Provided request body contains errors. Please check the data and retry the request", - }) - }) - - it("throws error when category ID is invalid", async () => { - const api = useApi() - const payload = { product_ids: [] } - - const error = await api - .post( - `/admin/product-categories/invalid-category-id/products/batch`, - payload, - adminHeaders - ) - .catch((e) => e) - - expect(error.response.status).toEqual(404) - expect(error.response.data).toEqual({ - message: "ProductCategory with id: invalid-category-id was not found", - type: "not_found", - }) - }) - - it("throws error trying to expand not allowed relations", async () => { - const api = useApi() - const payload = { product_ids: [] } - - const error = await api - .post( - `/admin/product-categories/invalid-category-id/products/batch?expand=products`, - payload, - adminHeaders - ) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data).toEqual({ - message: "Requested fields [products] are not valid", - type: "invalid_data", - }) - }) - }) - - describe("DELETE /admin/product-categories/:id/products/batch", () => { - let testProduct1, testProduct2 - - beforeEach(async () => { - await adminSeeder(dbConnection) - - testProduct1 = await simpleProductFactory(dbConnection, { - id: "test-product-1", - title: "test product 1", - }) - - testProduct2 = await simpleProductFactory(dbConnection, { - id: "test-product-2", - title: "test product 2", - }) - - productCategory = await simpleProductCategoryFactory(dbConnection, { - id: "test-category", - name: "test category", - products: [testProduct1, testProduct2], - }) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("should remove products from a product category", async () => { - const api = useApi() - - const payload = { - product_ids: [{ id: testProduct2.id }], - } - - const response = await api.delete( - `/admin/product-categories/${productCategory.id}/products/batch`, - { - ...adminHeaders, - data: payload, + // TODO: Remove in V2, unnecessary test + it("throws an error when description is not a string", async () => { + const payload = { + name: "test", + handle: "test", + description: null, } - ) - expect(response.status).toEqual(200) - expect(response.data.product_category).toEqual( - expect.objectContaining({ - id: productCategory.id, - created_at: expect.any(String), - updated_at: expect.any(String), - }) - ) + const error = await api + .post(`/admin/product-categories`, payload, adminHeaders) + .catch((e) => e) - const products = await dbConnection.manager.find(Product, { - where: { id: In([testProduct1.id, testProduct2.id]) }, - relations: ["categories"], + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual("invalid_data") + expect(error.response.data.message).toEqual( + "description must be a string" + ) }) - expect(products[0].categories).toEqual([ - expect.objectContaining({ - id: productCategory.id, - }), - ]) + it("successfully creates a product category", async () => { + const payload = { + name: "test", + handle: "test", + is_internal: true, + parent_category_id: productCategory.id, + description: "test", + } - expect(products[1].categories).toEqual([]) + const response = await api.post( + `/admin/product-categories`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + name: payload.name, + description: payload.description, + handle: payload.handle, + is_internal: payload.is_internal, + is_active: false, + created_at: expect.any(String), + updated_at: expect.any(String), + ...breaking( + () => ({ + parent_category: expect.objectContaining({ + id: productCategory.id, + }), + }), + () => ({ + parent_category_id: productCategory.id, + }) + ), + category_children: [], + rank: 0, + }), + }) + ) + }) + + it("successfully creates a product category with a rank", async () => { + const payload = { + name: "test", + handle: "test", + is_internal: true, + parent_category_id: productCategoryParent.id, + } + + const response = await api.post( + `/admin/product-categories`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + name: payload.name, + handle: payload.handle, + is_internal: payload.is_internal, + is_active: false, + created_at: expect.any(String), + updated_at: expect.any(String), + ...breaking( + () => ({ + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + }), + }), + () => ({ + parent_category_id: productCategoryParent.id, + }) + ), + category_children: [], + rank: 1, + }), + }) + ) + }) + + it("root parent returns children correctly on creating new category", async () => { + const response = await api.post( + `/admin/product-categories`, + { + name: "last descendant", + parent_category_id: productCategory.id, + }, + adminHeaders + ) + const lastDescendant = response.data.product_category + + const path = breaking( + () => `/admin/product-categories/${productCategoryParent.id}`, + () => + `/admin/product-categories/${productCategoryParent.id}?include_descendants_tree=true` + ) + + const parentResponse = await api.get(path, adminHeaders) + + expect(parentResponse.data.product_category).toEqual( + expect.objectContaining({ + id: productCategoryParent.id, + category_children: [ + expect.objectContaining({ + id: productCategory.id, + category_children: [ + expect.objectContaining({ + id: lastDescendant.id, + category_children: [], + }), + ], + }), + ], + }) + ) + }) }) - it("throws error when product ID is invalid", async () => { - const api = useApi() + describe("DELETE /admin/product-categories/:id", () => { + beforeEach(async () => { + productCategoryParent = await simpleProductCategoryFactory( + dbConnection, + { + name: "category parent", + handle: "category-parent", + } + ) - const payload = { - product_ids: [{ id: "product-id-invalid" }], - } + productCategory = await simpleProductCategoryFactory(dbConnection, { + name: "category", + handle: "category", + parent_category: productCategoryParent, + }) - const error = await api - .delete( + productCategory1 = await simpleProductCategoryFactory(dbConnection, { + name: "category-1", + parent_category: productCategoryParent, + rank: 1, + }) + + productCategory2 = await simpleProductCategoryFactory(dbConnection, { + name: "category-2", + parent_category: productCategoryParent, + rank: 2, + }) + }) + + it("returns successfully with an invalid ID", async () => { + const response = await api.delete( + `/admin/product-categories/invalid-id`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.id).toEqual("invalid-id") + expect(response.data.deleted).toBeTruthy() + expect(response.data.object).toEqual("product-category") + }) + + it("throws a not allowed error for a category with children", async () => { + const error = await api + .delete( + `/admin/product-categories/${productCategoryParent.id}`, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual("not_allowed") + expect(error.response.data.message).toEqual( + `Deleting ProductCategory (${productCategoryParent.id}) with category children is not allowed` + ) + }) + + it("deletes a product category with no children successfully", async () => { + const deleteResponse = await api + .delete( + `/admin/product-categories/${productCategory.id}`, + adminHeaders + ) + .catch((e) => e) + + expect(deleteResponse.status).toEqual(200) + expect(deleteResponse.data.id).toEqual(productCategory.id) + expect(deleteResponse.data.deleted).toBeTruthy() + expect(deleteResponse.data.object).toEqual("product-category") + + const errorFetchingDeleted = await api + .get(`/admin/product-categories/${productCategory.id}`, adminHeaders) + .catch((e) => e) + + expect(errorFetchingDeleted.response.status).toEqual(404) + }) + + it("deleting a product category reorders siblings accurately", async () => { + const deleteResponse = await api + .delete( + `/admin/product-categories/${productCategory.id}`, + adminHeaders + ) + .catch((e) => e) + + expect(deleteResponse.status).toEqual(200) + + const siblingsResponse = await api + .get( + `/admin/product-categories?parent_category_id=${productCategoryParent.id}`, + adminHeaders + ) + .catch((e) => e) + + expect(siblingsResponse.data.product_categories).toEqual([ + expect.objectContaining({ + id: productCategory1.id, + rank: 0, + }), + expect.objectContaining({ + id: productCategory2.id, + rank: 1, + }), + ]) + }) + }) + + describe("POST /admin/product-categories/:id", () => { + beforeEach(async () => { + productCategoryParent = await simpleProductCategoryFactory( + dbConnection, + { + name: "category parent", + } + ) + + productCategory = await simpleProductCategoryFactory(dbConnection, { + name: "category-0", + parent_category: productCategoryParent, + rank: 0, + }) + + productCategory1 = await simpleProductCategoryFactory(dbConnection, { + name: "category-1", + parent_category: productCategoryParent, + rank: 1, + }) + + productCategory2 = await simpleProductCategoryFactory(dbConnection, { + name: "category-2", + parent_category: productCategoryParent, + rank: 2, + }) + + productCategoryChild = await simpleProductCategoryFactory( + dbConnection, + { + name: "category child", + parent_category: productCategory, + rank: 0, + } + ) + + productCategoryChild0 = await simpleProductCategoryFactory( + dbConnection, + { + name: "category child 0", + parent_category: productCategoryChild, + rank: 0, + } + ) + + productCategoryChild1 = await simpleProductCategoryFactory( + dbConnection, + { + name: "category child 1", + parent_category: productCategoryChild, + rank: 1, + } + ) + + productCategoryChild2 = await simpleProductCategoryFactory( + dbConnection, + { + name: "category child 2", + parent_category: productCategoryChild, + rank: 2, + } + ) + + productCategoryChild3 = await simpleProductCategoryFactory( + dbConnection, + { + name: "category child 3", + parent_category: productCategoryChild, + rank: 3, + } + ) + }) + + it("throws an error if invalid ID is sent", async () => { + const error = await api + .post( + `/admin/product-categories/not-found-id`, + { + name: "testing", + }, + adminHeaders + ) + .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: not-found-id was not found" + ) + }) + + it("throws an error if rank is negative", async () => { + const error = await api + .post( + `/admin/product-categories/not-found-id`, + { + rank: -1, + }, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual("invalid_data") + expect(error.response.data.message).toEqual( + "rank must not be less than 0" + ) + }) + + it("throws an error if invalid attribute is sent", async () => { + const error = await api + .post( + `/admin/product-categories/${productCategory.id}`, + { + invalid_property: "string", + }, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual("invalid_data") + expect(error.response.data.message).toEqual( + "property invalid_property should not exist" + ) + }) + + it("successfully updates a product category", async () => { + const response = await api.post( + `/admin/product-categories/${productCategoryChild2.id}`, + { + name: "test", + handle: "test", + is_internal: true, + is_active: true, + parent_category_id: productCategory.id, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + name: "test", + handle: "test", + is_internal: true, + is_active: true, + created_at: expect.any(String), + updated_at: expect.any(String), + parent_category: expect.objectContaining({ + id: productCategory.id, + }), + category_children: [], + rank: 1, + }), + }) + ) + }) + + it("updating properties other than rank should not change its rank", async () => { + expect(productCategory.rank).toEqual(0) + + const response = await api.post( + `/admin/product-categories/${productCategory.id}`, + { + name: "different-name", + }, + adminHeaders + ) + expect(response.status).toEqual(200) + expect(response.data.product_category.rank).toEqual( + productCategory.rank + ) + }) + + it("root parent returns children correctly on updating new category", async () => { + const response = await api.post( + `/admin/product-categories/${productCategoryChild.id}`, + { + parent_category_id: productCategory.id, + }, + adminHeaders + ) + const lastDescendant = response.data.product_category + + const parentResponse = await api.get( + `/admin/product-categories/${productCategoryParent.id}`, + adminHeaders + ) + + expect(parentResponse.data.product_category).toEqual( + expect.objectContaining({ + id: productCategoryParent.id, + category_children: [ + expect.objectContaining({ + id: productCategory.id, + rank: 0, + category_children: [ + expect.objectContaining({ + id: productCategoryChild.id, + rank: 0, + }), + ], + }), + expect.objectContaining({ + id: productCategory1.id, + category_children: [], + rank: 1, + }), + expect.objectContaining({ + id: productCategory2.id, + category_children: [], + rank: 2, + }), + ], + }) + ) + }) + + it("when parent is updated, rank is updated to elements count + 1", async () => { + const response = await api.post( + `/admin/product-categories/${productCategoryChild1.id}`, + { + parent_category_id: productCategoryParent.id, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + }), + rank: 3, + }), + }) + ) + }) + + it("when parent is updated with rank, rank accurately updated", async () => { + const response = await api.post( + `/admin/product-categories/${productCategoryChild1.id}`, + { + parent_category_id: productCategoryParent.id, + rank: 0, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + parent_category: expect.objectContaining({ + id: productCategoryParent.id, + }), + rank: 0, + }), + }) + ) + }) + + it("when only rank is updated, rank should be updated", async () => { + const response = await api.post( + `/admin/product-categories/${productCategoryChild1.id}`, + { + rank: 0, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + rank: 0, + }), + }) + ) + }) + + it("when rank is greater than list count, rank is updated to updated to elements count + 1", async () => { + const response = await api.post( + `/admin/product-categories/${productCategoryChild1.id}`, + { + rank: 99, + }, + adminHeaders + ) + + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + rank: 3, + }), + }) + ) + }) + + it("when rank is updated, it accurately updates sibling ranks", async () => { + const response = await api.post( + `/admin/product-categories/${productCategoryChild2.id}`, + { + rank: 0, + }, + adminHeaders + ) + + const parentResponse = await api.get( + `/admin/product-categories/${productCategoryChild2.parent_category_id}`, + adminHeaders + ) + + expect(parentResponse.data.product_category).toEqual( + expect.objectContaining({ + id: productCategoryChild2.parent_category_id, + category_children: [ + expect.objectContaining({ + id: productCategoryChild2.id, + rank: 0, + }), + expect.objectContaining({ + id: productCategoryChild0.id, + rank: 1, + }), + expect.objectContaining({ + id: productCategoryChild1.id, + rank: 2, + }), + expect.objectContaining({ + id: productCategoryChild3.id, + rank: 3, + }), + ], + }) + ) + }) + }) + + describe("POST /admin/product-categories/:id/products/batch", () => { + beforeEach(async () => { + productCategory = await simpleProductCategoryFactory(dbConnection, { + id: "test-category", + name: "test category", + }) + }) + + it("should add products to a product category", async () => { + const testProduct1 = await simpleProductFactory(dbConnection, { + id: "test-product-1", + title: "test product 1", + }) + + const testProduct2 = await simpleProductFactory(dbConnection, { + id: "test-product-2", + title: "test product 2", + }) + + const payload = { + product_ids: [{ id: testProduct1.id }, { id: testProduct2.id }], + } + + const response = await api.post( + `/admin/product-categories/${productCategory.id}/products/batch`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product_category).toEqual( + expect.objectContaining({ + id: productCategory.id, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) + + const products = await dbConnection.manager.find(Product, { + where: { id: In([testProduct1.id, testProduct2.id]) }, + relations: ["categories"], + }) + + expect(products[0].categories).toEqual([ + expect.objectContaining({ + id: productCategory.id, + }), + ]) + + expect(products[1].categories).toEqual([ + expect.objectContaining({ + id: productCategory.id, + }), + ]) + }) + + it("throws error when product ID is invalid", async () => { + const payload = { + product_ids: [{ id: "product-id-invalid" }], + } + + const error = await api + .post( + `/admin/product-categories/${productCategory.id}/products/batch`, + payload, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + errors: ["Products product-id-invalid do not exist"], + message: + "Provided request body contains errors. Please check the data and retry the request", + }) + }) + + it("throws error when category ID is invalid", async () => { + const payload = { product_ids: [] } + + const error = await api + .post( + `/admin/product-categories/invalid-category-id/products/batch`, + payload, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data).toEqual({ + message: "ProductCategory with id: invalid-category-id was not found", + type: "not_found", + }) + }) + + it("throws error trying to expand not allowed relations", async () => { + const payload = { product_ids: [] } + + const error = await api + .post( + `/admin/product-categories/invalid-category-id/products/batch?expand=products`, + payload, + adminHeaders + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + message: "Requested fields [products] are not valid", + type: "invalid_data", + }) + }) + }) + + describe("DELETE /admin/product-categories/:id/products/batch", () => { + let testProduct1, testProduct2 + + beforeEach(async () => { + testProduct1 = await simpleProductFactory(dbConnection, { + id: "test-product-1", + title: "test product 1", + }) + + testProduct2 = await simpleProductFactory(dbConnection, { + id: "test-product-2", + title: "test product 2", + }) + + productCategory = await simpleProductCategoryFactory(dbConnection, { + id: "test-category", + name: "test category", + products: [testProduct1, testProduct2], + }) + }) + + it("should remove products from a product category", async () => { + const payload = { + product_ids: [{ id: testProduct2.id }], + } + + const response = await api.delete( `/admin/product-categories/${productCategory.id}/products/batch`, { ...adminHeaders, data: payload, } ) - .catch((e) => e) - expect(error.response.status).toEqual(400) - expect(error.response.data).toEqual({ - errors: ["Products product-id-invalid do not exist"], - message: - "Provided request body contains errors. Please check the data and retry the request", - }) - }) - - it("throws error when category ID is invalid", async () => { - const api = useApi() - const payload = { product_ids: [] } - - const error = await api - .delete( - `/admin/product-categories/invalid-category-id/products/batch`, - { - ...adminHeaders, - data: payload, - } + expect(response.status).toEqual(200) + expect(response.data.product_category).toEqual( + expect.objectContaining({ + id: productCategory.id, + created_at: expect.any(String), + updated_at: expect.any(String), + }) ) - .catch((e) => e) - expect(error.response.status).toEqual(404) - expect(error.response.data).toEqual({ - message: "ProductCategory with id: invalid-category-id was not found", - type: "not_found", + const products = await dbConnection.manager.find(Product, { + where: { id: In([testProduct1.id, testProduct2.id]) }, + relations: ["categories"], + }) + + expect(products[0].categories).toEqual([ + expect.objectContaining({ + id: productCategory.id, + }), + ]) + + expect(products[1].categories).toEqual([]) + }) + + it("throws error when product ID is invalid", async () => { + const payload = { + product_ids: [{ id: "product-id-invalid" }], + } + + const error = await api + .delete( + `/admin/product-categories/${productCategory.id}/products/batch`, + { + ...adminHeaders, + data: payload, + } + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + errors: ["Products product-id-invalid do not exist"], + message: + "Provided request body contains errors. Please check the data and retry the request", + }) + }) + + it("throws error when category ID is invalid", async () => { + const payload = { product_ids: [] } + + const error = await api + .delete( + `/admin/product-categories/invalid-category-id/products/batch`, + { + ...adminHeaders, + data: payload, + } + ) + .catch((e) => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data).toEqual({ + message: "ProductCategory with id: invalid-category-id was not found", + type: "not_found", + }) + }) + + it("throws error trying to expand not allowed relations", async () => { + const payload = { product_ids: [] } + + const error = await api + .delete( + `/admin/product-categories/invalid-category-id/products/batch?expand=products`, + { + ...adminHeaders, + data: payload, + } + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + message: "Requested fields [products] are not valid", + type: "invalid_data", + }) }) }) - - it("throws error trying to expand not allowed relations", async () => { - const api = useApi() - const payload = { product_ids: [] } - - const error = await api - .delete( - `/admin/product-categories/invalid-category-id/products/batch?expand=products`, - { - ...adminHeaders, - data: payload, - } - ) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data).toEqual({ - message: "Requested fields [products] are not valid", - type: "invalid_data", - }) - }) - }) + }, }) diff --git a/packages/core-flows/src/index.ts b/packages/core-flows/src/index.ts index 6b1193f347..392a1ea1c6 100644 --- a/packages/core-flows/src/index.ts +++ b/packages/core-flows/src/index.ts @@ -6,6 +6,7 @@ export * from "./customer-group" export * from "./defaults" export * from "./definition" export * from "./definitions" +export * from "./file" export * from "./fulfillment" export * as Handlers from "./handlers" export * from "./inventory" @@ -15,6 +16,7 @@ export * from "./payment" export * from "./price-list" export * from "./pricing" export * from "./product" +export * from "./product-category" export * from "./promotion" export * from "./reservation" export * from "./region" @@ -24,4 +26,3 @@ export * from "./stock-location" export * from "./store" export * from "./tax" export * from "./user" -export * from "./file" diff --git a/packages/core-flows/src/product-category/index.ts b/packages/core-flows/src/product-category/index.ts new file mode 100644 index 0000000000..68de82c9f9 --- /dev/null +++ b/packages/core-flows/src/product-category/index.ts @@ -0,0 +1,2 @@ +export * from "./steps" +export * from "./workflows" diff --git a/packages/core-flows/src/product-category/steps/create-product-category.ts b/packages/core-flows/src/product-category/steps/create-product-category.ts new file mode 100644 index 0000000000..f62bee80a8 --- /dev/null +++ b/packages/core-flows/src/product-category/steps/create-product-category.ts @@ -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( + 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( + ModuleRegistrationName.PRODUCT + ) + + await service.deleteCategory(createdId) + } +) diff --git a/packages/core-flows/src/product-category/steps/index.ts b/packages/core-flows/src/product-category/steps/index.ts new file mode 100644 index 0000000000..cf4dfb072e --- /dev/null +++ b/packages/core-flows/src/product-category/steps/index.ts @@ -0,0 +1 @@ +export * from "./create-product-category" diff --git a/packages/core-flows/src/product-category/workflows/create-product-category.ts b/packages/core-flows/src/product-category/workflows/create-product-category.ts new file mode 100644 index 0000000000..e83ba818dd --- /dev/null +++ b/packages/core-flows/src/product-category/workflows/create-product-category.ts @@ -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) => { + const category = createProductCategoryStep(input) + + return category + } +) diff --git a/packages/core-flows/src/product-category/workflows/index.ts b/packages/core-flows/src/product-category/workflows/index.ts new file mode 100644 index 0000000000..cf4dfb072e --- /dev/null +++ b/packages/core-flows/src/product-category/workflows/index.ts @@ -0,0 +1 @@ +export * from "./create-product-category" diff --git a/packages/medusa/src/api-v2/admin/product-categories/middlewares.ts b/packages/medusa/src/api-v2/admin/product-categories/middlewares.ts index d7c30229e8..b5755a987c 100644 --- a/packages/medusa/src/api-v2/admin/product-categories/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/product-categories/middlewares.ts @@ -1,8 +1,10 @@ import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/authenticate-middleware" +import { validateAndTransformBody } from "../../utils/validate-body" import { validateAndTransformQuery } from "../../utils/validate-query" import * as QueryConfig from "./query-config" import { + AdminCreateProductCategory, AdminProductCategoriesParams, AdminProductCategoryParams, } from "./validators" @@ -33,4 +35,15 @@ export const adminProductCategoryRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/product-categories", + middlewares: [ + validateAndTransformBody(AdminCreateProductCategory), + validateAndTransformQuery( + AdminProductCategoryParams, + QueryConfig.retrieveProductCategoryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api-v2/admin/product-categories/query-config.ts b/packages/medusa/src/api-v2/admin/product-categories/query-config.ts index 54c4298cee..9e93ba4276 100644 --- a/packages/medusa/src/api-v2/admin/product-categories/query-config.ts +++ b/packages/medusa/src/api-v2/admin/product-categories/query-config.ts @@ -11,10 +11,24 @@ export const defaults = [ "updated_at", "metadata", - "parent_category.id", - "parent_category.name", - "category_children.id", - "category_children.name", + "*category_children", +] + +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 = { diff --git a/packages/medusa/src/api-v2/admin/product-categories/route.ts b/packages/medusa/src/api-v2/admin/product-categories/route.ts index 3b30d8e53a..126109b84d 100644 --- a/packages/medusa/src/api-v2/admin/product-categories/route.ts +++ b/packages/medusa/src/api-v2/admin/product-categories/route.ts @@ -1,4 +1,8 @@ -import { AdminProductCategoryListResponse } from "@medusajs/types" +import { createProductCategoryWorkflow } from "@medusajs/core-flows" +import { + AdminProductCategoryListResponse, + AdminProductCategoryResponse, +} from "@medusajs/types" import { ContainerRegistrationKeys, remoteQueryObjectFromString, @@ -7,7 +11,10 @@ import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../types/routing" -import { AdminProductCategoriesParamsType } from "./validators" +import { + AdminCreateProductCategoryType, + AdminProductCategoriesParamsType, +} from "./validators" export const GET = async ( req: AuthenticatedMedusaRequest, @@ -33,3 +40,33 @@ export const GET = async ( limit: metadata.take, }) } + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + 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 }) +} diff --git a/packages/medusa/src/api-v2/admin/product-categories/validators.ts b/packages/medusa/src/api-v2/admin/product-categories/validators.ts index c9dae51403..7317a53b37 100644 --- a/packages/medusa/src/api-v2/admin/product-categories/validators.ts +++ b/packages/medusa/src/api-v2/admin/product-categories/validators.ts @@ -43,6 +43,14 @@ export const AdminProductCategoriesParams = createFindParams({ (val: any) => optionalBooleanMapper.get(val?.toLowerCase()), 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(), updated_at: createOperatorMap().optional(), deleted_at: createOperatorMap().optional(), @@ -50,3 +58,19 @@ export const AdminProductCategoriesParams = createFindParams({ $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 +> diff --git a/packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts b/packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts index 967223f329..d549030f90 100644 --- a/packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts +++ b/packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts @@ -1,10 +1,9 @@ import { Modules } from "@medusajs/modules-sdk" import { IProductModuleService, ProductTypes } from "@medusajs/types" 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 { productCategoriesRankData } from "../../../__fixtures__/product-category/data" -import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils" jest.setTimeout(30000) diff --git a/packages/product/src/repositories/product-category.ts b/packages/product/src/repositories/product-category.ts index b098acff87..5741510030 100644 --- a/packages/product/src/repositories/product-category.ts +++ b/packages/product/src/repositories/product-category.ts @@ -4,11 +4,7 @@ import { ProductCategoryTransformOptions, ProductTypes, } from "@medusajs/types" -import { - DALUtils, - MedusaError, - isDefined -} from "@medusajs/utils" +import { DALUtils, MedusaError, isDefined } from "@medusajs/utils" import { LoadStrategy, FilterQuery as MikroFilterQuery, diff --git a/packages/product/src/services/product-module-service.ts b/packages/product/src/services/product-module-service.ts index efe4b6672c..e2b70147ed 100644 --- a/packages/product/src/services/product-module-service.ts +++ b/packages/product/src/services/product-module-service.ts @@ -915,11 +915,21 @@ export default class ProductModuleService< ) } - @InjectTransactionManager("baseRepository_") + @InjectManager("baseRepository_") async createCategory( data: ProductTypes.CreateProductCategoryDTO, @MedusaContext() sharedContext: Context = {} ): Promise { + const result = await this.createCategory_(data, sharedContext) + + return await this.baseRepository_.serialize(result) + } + + @InjectTransactionManager("baseRepository_") + async createCategory_( + data: ProductTypes.CreateProductCategoryDTO, + @MedusaContext() sharedContext: Context = {} + ): Promise { const productCategory = await this.productCategoryService_.create( data, sharedContext @@ -930,9 +940,7 @@ export default class ProductModuleService< { id: productCategory.id } ) - return await this.baseRepository_.serialize(productCategory, { - populate: true, - }) + return productCategory } @InjectTransactionManager("baseRepository_") @@ -1115,8 +1123,10 @@ export default class ProductModuleService< data: ProductTypes.CreateProductDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { - const normalizedInput = await Promise.all( - data.map((d) => this.normalizeCreateProductInput(d, sharedContext)) + const normalizedInput = await promiseAll( + data.map( + async (d) => await this.normalizeCreateProductInput(d, sharedContext) + ) ) const productData = await this.productService_.upsertWithReplace( @@ -1171,8 +1181,10 @@ export default class ProductModuleService< data: UpdateProductInput[], @MedusaContext() sharedContext: Context = {} ): Promise { - const normalizedInput = await Promise.all( - data.map((d) => this.normalizeUpdateProductInput(d, sharedContext)) + const normalizedInput = await promiseAll( + data.map( + async (d) => await this.normalizeUpdateProductInput(d, sharedContext) + ) ) const productData = await this.productService_.upsertWithReplace( @@ -1258,7 +1270,8 @@ export default class ProductModuleService< @MedusaContext() sharedContext: Context = {} ): Promise { const productData = (await this.normalizeUpdateProductInput( - product as UpdateProductInput + product as UpdateProductInput, + sharedContext )) as ProductTypes.CreateProductDTO if (!productData.handle && productData.title) { diff --git a/packages/types/src/product/common.ts b/packages/types/src/product/common.ts index 82245a5f42..a1453b7cc5 100644 --- a/packages/types/src/product/common.ts +++ b/packages/types/src/product/common.ts @@ -358,11 +358,8 @@ export interface CreateProductCategoryDTO { rank?: number /** * 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. */ diff --git a/packages/types/src/workflow/index.ts b/packages/types/src/workflow/index.ts index 602eb649d6..3bd928bcba 100644 --- a/packages/types/src/workflow/index.ts +++ b/packages/types/src/workflow/index.ts @@ -1,10 +1,12 @@ export * as CartWorkflow from "./cart" 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 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 UserWorkflow from "./user" + diff --git a/packages/types/src/workflow/product-category/index.ts b/packages/types/src/workflow/product-category/index.ts new file mode 100644 index 0000000000..3c6a32a930 --- /dev/null +++ b/packages/types/src/workflow/product-category/index.ts @@ -0,0 +1,5 @@ +import { CreateProductCategoryDTO } from "../../product" + +export interface CreateProductCategoryWorkflowInput { + product_category: CreateProductCategoryDTO +}