From 29564bc11ea98688513b4bca42d4d19bdfc95e7d Mon Sep 17 00:00:00 2001 From: Andoumeda <107519532+Andoumeda@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:05:20 -0300 Subject: [PATCH] =?UTF-8?q?Endpoint=20para=20crear=20categor=C3=ADa=20como?= =?UTF-8?q?=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schemas/admin/admin-category.schema.js | 22 +++++ .../categories/admin-category.controller.js | 12 ++- .../admin/categories/admin-category.routes.js | 31 ++++++ .../categories/admin-category.service.js | 55 +++++++++++ tests/unit/admin/admin-category.test.js | 96 +++++++++++++++++++ 5 files changed, 215 insertions(+), 1 deletion(-) diff --git a/src/docs/schemas/admin/admin-category.schema.js b/src/docs/schemas/admin/admin-category.schema.js index e05ca01..7eb0014 100644 --- a/src/docs/schemas/admin/admin-category.schema.js +++ b/src/docs/schemas/admin/admin-category.schema.js @@ -1,4 +1,26 @@ export const adminCategorySchemas = { + AdminCreateCategoryBody: { + type: "object", + required: ["name"], + properties: { + name: { + type: "string", + minLength: 1, + maxLength: 100, + example: "Electrónica" + } + } + }, + AdminCreateCategoryResponse: { + type: "object", + properties: { + id: { type: "integer", example: 15 }, + name: { type: "string", example: "Electrónica" }, + visible: { type: "boolean", example: true }, + status: { type: "boolean", example: true }, + createdAt: { type: "string", format: "date-time" } + } + }, AdminCategoryResponse: { type: "object", properties: { diff --git a/src/modules/admin/categories/admin-category.controller.js b/src/modules/admin/categories/admin-category.controller.js index b381013..8580d33 100644 --- a/src/modules/admin/categories/admin-category.controller.js +++ b/src/modules/admin/categories/admin-category.controller.js @@ -5,10 +5,20 @@ import { filterCategoriesWithProducts, deleteAdminProductCategoryService, updateAdminProductCategoryService, - processAdminCategoryRequestService + processAdminCategoryRequestService, + createAdminProductCategoryService } from "./admin-category.service.js"; import { PAGINATION } from "../../../utils/contants/pagination.constant.js"; +export const createAdminProductCategory = async (req, res, next) => { + try { + const result = await createAdminProductCategoryService(req.body?.name); + return res.status(201).json(result); + } catch (error) { + next(error); + } +}; + export const getAdminProductCategory = async (req, res, next) => { try { const id = validateId(req.params.id); diff --git a/src/modules/admin/categories/admin-category.routes.js b/src/modules/admin/categories/admin-category.routes.js index e955a28..fb80684 100644 --- a/src/modules/admin/categories/admin-category.routes.js +++ b/src/modules/admin/categories/admin-category.routes.js @@ -2,6 +2,7 @@ import { Router } from "express"; import authenticate from "../../../config/jwt.config.js"; import { requireRole } from "../../../middlewares/auth.middleware.js"; import { + createAdminProductCategory, getAdminProductCategory, getAdminCategories, getAdminCategoriesWithProducts, @@ -13,6 +14,36 @@ import { ROLES } from "../../../utils/contants/roles.constant.js"; const router = Router(); +/** + * @swagger + * /api/admin/categories: + * post: + * summary: Crear categoría visible (Admin) + * tags: [Admin] + * security: + * - cookieAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdminCreateCategoryBody' + * responses: + * 201: + * description: Categoría creada correctamente + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdminCreateCategoryResponse' + * 400: + * description: Nombre inválido o categoría activa duplicada + * 401: + * description: No autenticado + * 403: + * description: Sin permisos de administrador + */ +router.post("/", authenticate, requireRole(ROLES.ADMIN), createAdminProductCategory); + /** * @swagger * /api/admin/categories/{id}: diff --git a/src/modules/admin/categories/admin-category.service.js b/src/modules/admin/categories/admin-category.service.js index 90f4081..f657dac 100644 --- a/src/modules/admin/categories/admin-category.service.js +++ b/src/modules/admin/categories/admin-category.service.js @@ -7,6 +7,61 @@ import { } from "../../../lib/product-pricing.js"; import { PAGINATION } from "../../../utils/contants/pagination.constant.js"; +const normalizeAdminCategoryName = (name) => { + if (typeof name !== "string" || name.trim().length === 0) { + throw new ValidationError("El nombre de la categoría no puede estar vacío"); + } + + const normalizedName = name.trim(); + if (normalizedName.length > 100) { + throw new ValidationError("El nombre de la categoría no puede superar 100 caracteres"); + } + + return normalizedName; +}; + +export const createAdminProductCategoryService = async (name) => { + const normalizedName = normalizeAdminCategoryName(name); + + const existingCategory = await prisma.productCategories.findFirst({ + where: { + name: { + equals: normalizedName, + mode: "insensitive" + }, + status: true + }, + select: { id_product_category: true } + }); + + if (existingCategory) { + throw new ValidationError("Ya existe una categoría con ese nombre"); + } + + const createdCategory = await prisma.productCategories.create({ + data: { + name: normalizedName, + visible: true, + status: true + }, + select: { + id_product_category: true, + name: true, + visible: true, + status: true, + created_at: true + } + }); + + return { + id: createdCategory.id_product_category, + name: createdCategory.name, + visible: createdCategory.visible, + status: createdCategory.status, + createdAt: createdCategory.created_at + }; +}; + export const getAdminProductCategoryService = async (id) => { const category = await prisma.productCategories.findUnique({ where: { id_product_category: id }, diff --git a/tests/unit/admin/admin-category.test.js b/tests/unit/admin/admin-category.test.js index 08574d0..c64dc36 100644 --- a/tests/unit/admin/admin-category.test.js +++ b/tests/unit/admin/admin-category.test.js @@ -6,6 +6,8 @@ import { prisma } from "../../../src/lib/prisma.js"; vi.mock("../../../src/lib/prisma.js", () => ({ prisma: { productCategories: { + findFirst: vi.fn(), + create: vi.fn(), findUnique: vi.fn(), update: vi.fn(), count: vi.fn(), @@ -103,6 +105,100 @@ const mockCategoryWithProducts = [ } ]; +// ─── POST /api/admin/categories ─────────────────────────────────── +describe("POST /api/admin/categories", () => { + beforeEach(() => vi.resetAllMocks()); + + it("devuelve 401 cuando no hay token", async () => { + const res = await request(app) + .post("/api/admin/categories") + .send({ name: "Electrónica" }); + + expect(res.status).toBe(401); + }); + + it("devuelve 403 cuando el usuario no es ADMIN", async () => { + const res = await asRole( + request(app) + .post("/api/admin/categories") + .send({ name: "Electrónica" }), + "seller" + ); + + expect(res.status).toBe(403); + }); + + it("devuelve 400 cuando name está vacío", async () => { + const res = await asRole( + request(app) + .post("/api/admin/categories") + .send({ name: " " }), + "admin" + ); + + expect(res.status).toBe(400); + expect(res.body.error.message).toMatch(/no puede estar vacío/i); + }); + + it("devuelve 400 cuando ya existe una categoría activa con el mismo nombre", async () => { + prisma.productCategories.findFirst.mockResolvedValue({ id_product_category: 10 }); + + const res = await asRole( + request(app) + .post("/api/admin/categories") + .send({ name: "electrónica" }), + "admin" + ); + + expect(res.status).toBe(400); + expect(res.body.error.message).toBe("Ya existe una categoría con ese nombre"); + expect(prisma.productCategories.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + name: { equals: "electrónica", mode: "insensitive" }, + status: true + }) + }) + ); + }); + + it("devuelve 201 y crea categoría con visible=true y status=true", async () => { + prisma.productCategories.findFirst.mockResolvedValue(null); + prisma.productCategories.create.mockResolvedValue({ + id_product_category: 22, + name: "Electrónica", + visible: true, + status: true, + created_at: now + }); + + const res = await asRole( + request(app) + .post("/api/admin/categories") + .send({ name: " Electrónica " }), + "admin" + ); + + expect(res.status).toBe(201); + expect(prisma.productCategories.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: "Electrónica", + visible: true, + status: true + }) + }) + ); + expect(res.body).toEqual({ + id: 22, + name: "Electrónica", + visible: true, + status: true, + createdAt: now + }); + }); +}); + // ─── GET /api/admin/categories/:id ──────────────────────────────── describe("GET /api/admin/categories/:id", () => { beforeEach(() => vi.resetAllMocks());