Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/docs/schemas/admin/admin-category.schema.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
12 changes: 11 additions & 1 deletion src/modules/admin/categories/admin-category.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions src/modules/admin/categories/admin-category.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}:
Expand Down
57 changes: 56 additions & 1 deletion src/modules/admin/categories/admin-category.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,62 @@ import {
getOfferProductPrice
} 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");
}
Comment on lines +37 to +39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Status HTTP inconsistente con el flujo de commerce para el mismo error.

En createCategoryRequestService (commerce) el duplicado se lanza como ConflictError → 409, acá lo lanzan como ValidationError → 400 con el mismo significado semántico ("ya existe una categoría con ese nombre"). Los clientes reciben códigos diferentes para la misma clase de error. Sugiero usar ConflictError también acá para consistencia de API (y ajustar el test de admin-category.test.js línea 153 que espera 400).

🔧 Cambio propuesto
-import { NotFoundError, ValidationError } from "../../../lib/errors.js";
+import { NotFoundError, ValidationError, ConflictError } from "../../../lib/errors.js";
@@
   if (existingCategory) {
-    throw new ValidationError("Ya existe una categoría con ese nombre");
+    throw new ConflictError("Ya existe una categoría con ese nombre");
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (existingCategory) {
throw new ValidationError("Ya existe una categoría con ese nombre");
}
import { NotFoundError, ValidationError, ConflictError } from "../../../lib/errors.js";
// ... other code ...
if (existingCategory) {
throw new ConflictError("Ya existe una categoría con ese nombre");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/admin/categories/admin-category.service.js` around lines 37 - 39,
The duplicate-category branch in admin-category.service.js currently throws
ValidationError when existingCategory is truthy; change that to throw
ConflictError (to match createCategoryRequestService's semantics and return 409)
by replacing the ValidationError instantiation in the existingCategory check
with ConflictError, and update the corresponding test expectation in
admin-category.test.js (the test around the duplicate-name case) to expect 409
instead of 400.


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
}
});
Comment on lines +23 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misma condición de carrera (TOCTOU) que en el servicio de commerce.

findFirst + create sin transacción ni índice único permite duplicados bajo concurrencia. Ver el comentario detallado en category-request.service.js; la solución (unique index case-insensitive + manejo de P2002) aplica también acá.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/admin/categories/admin-category.service.js` around lines 23 - 54,
createAdminProductCategoryService suffers a TOCTOU race: using findFirst then
create can produce duplicates under concurrency; ensure a case-insensitive
unique index exists on productCategories.name (or a functional index) at the
DB/schema level and remove reliance on findFirst for uniqueness, then wrap the
prisma.productCategories.create call in a try/catch and handle Prisma's
unique-constraint error (Prisma.PrismaClientKnownRequestError with code "P2002")
by throwing the same ValidationError ("Ya existe una categoría con ese nombre");
keep the normalizedName logic and the create payload but rely on the DB unique
index + P2002 handling to enforce uniqueness.


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 },
Expand Down
96 changes: 96 additions & 0 deletions tests/unit/admin/admin-category.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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());
Expand Down
Loading