diff --git a/src/app.js b/src/app.js index fd2b6d4..f6341eb 100644 --- a/src/app.js +++ b/src/app.js @@ -29,10 +29,18 @@ import { setupSwagger } from "./config/swagger.config.js"; import distanceRoutes from "./modules/global/distances/routes/distance.routes.js"; -import productImageRoutes from './modules/images/routes/product-image.routes.js'; -import userImageRoutes from './modules/images/routes/user-image.routes.js'; -import storeImageRoutes from './modules/images/routes/store-image.routes.js'; -import adminUserRoutes from "./modules/admin/users/admin-users.routes.js"; +// Rutas de imágenes +import { + productImageRoutes, + userImageRoutes, + storeImageRoutes, +} from './modules/images/routes/index.js'; + +// Rutas de administración +import { + adminUsersRoutes, + adminCategoryRoutes, +} from "./modules/admin/index.js"; const app = express(); @@ -75,7 +83,8 @@ app.use('/api/session', sessionRoutes); app.use("/api/orders", orderRouter); // Rutas de administración -app.use("/api/admin", adminUserRoutes); +app.use("/api/admin", adminUsersRoutes); +app.use("/api/admin/categories", adminCategoryRoutes); // Rutas de distancias app.use("/api/distances", distanceRoutes); diff --git a/src/config/swagger.config.js b/src/config/swagger.config.js index fb1b5b4..fc83ae8 100644 --- a/src/config/swagger.config.js +++ b/src/config/swagger.config.js @@ -2,6 +2,7 @@ import swaggerJsdoc from "swagger-jsdoc"; import swaggerUi from "swagger-ui-express"; import { commonSchemas, + imageSchemas, // /commerce addressSchemas, storeSchemas, @@ -10,7 +11,8 @@ import { productSchemas, // /session sessionSchemas, - imageSchemas + // /admin + adminCategorySchemas, } from "../docs/schemas/index.js"; const options = { @@ -43,6 +45,7 @@ const options = { { name: "Users", description: "Gestión de usuarios" }, { name: "Orders", description: "Gestión de pedidos" }, { name: "Images", description: "Gestión de imágenes" }, + { name: "Admin", description: "Gestión de administración" }, ], components: { securitySchemes: { @@ -64,7 +67,9 @@ const options = { // /session ...sessionSchemas, // /images - ...imageSchemas + ...imageSchemas, + // /admin + ...adminCategorySchemas, } }, security: [ diff --git a/src/docs/schemas/admin/admin-category.schema.js b/src/docs/schemas/admin/admin-category.schema.js new file mode 100644 index 0000000..a0b40e5 --- /dev/null +++ b/src/docs/schemas/admin/admin-category.schema.js @@ -0,0 +1,13 @@ +export const adminCategorySchemas = { + AdminCategoryResponse: { + type: "object", + properties: { + id: { type: "integer", example: 1 }, + name: { type: "string", example: "Electrónica" }, + status: { type: "boolean", example: true }, + productCount: { type: "integer", example: 42 }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" } + } + } +}; \ No newline at end of file diff --git a/src/docs/schemas/index.js b/src/docs/schemas/index.js index 8508e57..c8a2c79 100644 --- a/src/docs/schemas/index.js +++ b/src/docs/schemas/index.js @@ -1,4 +1,5 @@ export * from "./common.schema.js"; +export * from "./image.schema.js"; export * from "./commerce/address.schema.js"; export * from "./commerce/store.schema.js"; @@ -8,4 +9,4 @@ export * from "./commerce/product.schema.js"; export * from "./session/session.schema.js"; -export * from "./image.schema.js"; +export * from "./admin/admin-category.schema.js"; \ No newline at end of file diff --git a/src/docs/schemas/product.schema.js b/src/docs/schemas/product.schema.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/docs/schemas/review.schema.js b/src/docs/schemas/review.schema.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/validators.js b/src/lib/validators.js index 672c00d..dfba612 100644 --- a/src/lib/validators.js +++ b/src/lib/validators.js @@ -1,5 +1,13 @@ import { ValidationError } from "./errors.js"; +export const validateId = (value) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new ValidationError(`ID debe ser un entero mayor a 0`); + } + return parsed; +}; + export const parsePositiveInteger = (value, fieldName) => { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) { diff --git a/src/modules/admin/categories/admin-category.controller.js b/src/modules/admin/categories/admin-category.controller.js new file mode 100644 index 0000000..99adeaa --- /dev/null +++ b/src/modules/admin/categories/admin-category.controller.js @@ -0,0 +1,12 @@ +import { validateId } from "../../../lib/validators.js"; +import { getAdminProductCategoryService } from "./admin-category.service.js"; + +export const getAdminProductCategory = async (req, res, next) => { + try { + const id = validateId(req.params.id); + const result = await getAdminProductCategoryService(id); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/src/modules/admin/categories/admin-category.routes.js b/src/modules/admin/categories/admin-category.routes.js new file mode 100644 index 0000000..942cac3 --- /dev/null +++ b/src/modules/admin/categories/admin-category.routes.js @@ -0,0 +1,41 @@ +import { Router } from "express"; +import authenticate from "../../../config/jwt.config.js"; +import { requireRole } from "../../../middlewares/auth.middleware.js"; +import { getAdminProductCategory } from "./admin-category.controller.js"; +import { ROLES } from "../../../utils/contants/roles.constant.js"; + +const router = Router(); + +/** + * @swagger + * /api/admin/categories/{id}: + * get: + * summary: Ver categoría de productos (Admin) + * tags: [Admin] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Categoría encontrada + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdminCategoryResponse' + * 400: + * description: ID inválido + * 401: + * description: No autenticado + * 403: + * description: Sin permisos de administrador + * 404: + * description: Categoría no encontrada + */ +router.get("/:id", authenticate, requireRole(ROLES.ADMIN), getAdminProductCategory); + +export { router as adminCategoryRoutes }; \ No newline at end of file diff --git a/src/modules/admin/categories/admin-category.service.js b/src/modules/admin/categories/admin-category.service.js new file mode 100644 index 0000000..36962e5 --- /dev/null +++ b/src/modules/admin/categories/admin-category.service.js @@ -0,0 +1,27 @@ +import { prisma } from "../../../lib/prisma.js"; +import { NotFoundError } from "../../../lib/errors.js"; + +export const getAdminProductCategoryService = async (id) => { + const category = await prisma.productCategories.findUnique({ + where: { id_product_category: id }, + select: { + id_product_category: true, + name: true, + status: true, + created_at: true, + updated_at: true, + _count: { select: { products: true } } + } + }); + + if (!category) throw new NotFoundError("Categoría no encontrada"); + + return { + id: category.id_product_category, + name: category.name, + status: category.status, + productCount: category._count.products, + createdAt: category.created_at, + updatedAt: category.updated_at + }; +}; \ No newline at end of file diff --git a/src/modules/admin/index.js b/src/modules/admin/index.js new file mode 100644 index 0000000..11003ea --- /dev/null +++ b/src/modules/admin/index.js @@ -0,0 +1,2 @@ +export * from "./categories/admin-category.routes.js"; +export * from "./users/admin-users.routes.js"; \ No newline at end of file diff --git a/src/modules/admin/users/admin-users.routes.js b/src/modules/admin/users/admin-users.routes.js index cedf1d0..bdf19c2 100644 --- a/src/modules/admin/users/admin-users.routes.js +++ b/src/modules/admin/users/admin-users.routes.js @@ -10,4 +10,4 @@ const router = Router(); // GET /api/admin/users?search=&role=&status=&page=&limit= router.get("/users", authenticate, requireRole(ROLES.ADMIN), parsePagination, getUsers); -export default router; \ No newline at end of file +export { router as adminUsersRoutes }; \ No newline at end of file diff --git a/src/modules/global/categories/.gitkeep b/src/modules/global/categories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/images/routes/index.js b/src/modules/images/routes/index.js new file mode 100644 index 0000000..2c9abce --- /dev/null +++ b/src/modules/images/routes/index.js @@ -0,0 +1,3 @@ +export * from "./product-image.routes.js"; +export * from "./user-image.routes.js"; +export * from "./store-image.routes.js"; \ No newline at end of file diff --git a/src/modules/images/routes/product-image.routes.js b/src/modules/images/routes/product-image.routes.js index c718054..9e668a7 100644 --- a/src/modules/images/routes/product-image.routes.js +++ b/src/modules/images/routes/product-image.routes.js @@ -20,4 +20,4 @@ router.post('/:id/image', authenticate, requireRole(ROLES.ADMIN, ROLES.SELLER), router.put('/:id/image', authenticate, requireRole(ROLES.ADMIN, ROLES.SELLER), upload.single('image'), updateProductImage) router.delete('/:id/image', authenticate, requireRole(ROLES.ADMIN, ROLES.SELLER), deleteProductImage) -export default router \ No newline at end of file +export { router as productImageRoutes } \ No newline at end of file diff --git a/src/modules/images/routes/store-image.routes.js b/src/modules/images/routes/store-image.routes.js index 8747e3f..30797b8 100644 --- a/src/modules/images/routes/store-image.routes.js +++ b/src/modules/images/routes/store-image.routes.js @@ -17,4 +17,4 @@ router.post('/:id/image', authenticate, requireRole(ROLES.ADMIN, ROLES.SELLER), router.put('/:id/image', authenticate, requireRole(ROLES.ADMIN, ROLES.SELLER), upload.single('image'), updateStoreImage) router.delete('/:id/image', authenticate, requireRole(ROLES.ADMIN, ROLES.SELLER), deleteStoreImage) -export default router \ No newline at end of file +export { router as storeImageRoutes } \ No newline at end of file diff --git a/src/modules/images/routes/user-image.routes.js b/src/modules/images/routes/user-image.routes.js index 76045a1..2d2fedf 100644 --- a/src/modules/images/routes/user-image.routes.js +++ b/src/modules/images/routes/user-image.routes.js @@ -1,7 +1,6 @@ import { Router } from 'express' import { upload } from '../../../middlewares/upload.middleware.js' import authenticate from '../../../config/jwt.config.js' -import { requireRole } from '../../../middlewares/auth.middleware.js' import { getUserImage, uploadUserImage, @@ -19,4 +18,4 @@ router.post('/:id/image', authenticate, upload.single('image'), uploadUserImage) router.put('/:id/image', authenticate, upload.single('image'), updateUserImage) router.delete('/:id/image', authenticate, deleteUserImage) -export default router \ No newline at end of file +export { router as userImageRoutes } \ No newline at end of file diff --git a/tests/unit/admin/admin-category.test.js b/tests/unit/admin/admin-category.test.js new file mode 100644 index 0000000..2d5224a --- /dev/null +++ b/tests/unit/admin/admin-category.test.js @@ -0,0 +1,143 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../../src/app.js"; +import { prisma } from "../../../src/lib/prisma.js"; + +// ─── MOCKS ──────────────────────────────────────────────────────── +vi.mock("../../../src/lib/prisma.js", () => ({ + prisma: { + productCategories: { findUnique: vi.fn() } + } +})); + +vi.mock("../../../src/config/jwt.config.js", () => ({ + default: (req, res, next) => { + const role = req.headers["x-test-role"]; + + if (!role) { + return res.status(401).json({ errors: { auth: { message: "No autenticado" } } }); + } + + const users = { + admin: { id_user: 1, role: "ADMIN" }, + seller: { id_user: 10, role: "SELLER" }, + customer: { id_user: 2, role: "CUSTOMER" }, + }; + + req.user = users[role] ?? null; + + if (!req.user) { + return res.status(401).json({ errors: { auth: { message: "No autenticado" } } }); + } + + next(); + } +})); + +// ─── HELPERS ────────────────────────────────────────────────────── +const asRole = (req, role) => req.set("x-test-role", role); + +const mockCategory = { + id_product_category: 1, + name: "Electrónica", + status: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + _count: { products: 5 } +}; + +// ─── GET /api/admin/categories/:id ──────────────────────────────── +describe("GET /api/admin/categories/:id", () => { + beforeEach(() => vi.resetAllMocks()); + + it("devuelve 401 cuando no hay token", async () => { + const res = await request(app).get("/api/admin/categories/1"); + + expect(res.status).toBe(401); + }); + + it("devuelve 403 cuando el usuario no es ADMIN", async () => { + const res = await asRole( + request(app).get("/api/admin/categories/1"), + "seller" + ); + + expect(res.status).toBe(403); + }); + + it("devuelve 403 cuando el usuario es CUSTOMER", async () => { + const res = await asRole( + request(app).get("/api/admin/categories/1"), + "customer" + ); + + expect(res.status).toBe(403); + }); + + it("devuelve 400 cuando el id no es un entero válido", async () => { + const res = await asRole( + request(app).get("/api/admin/categories/abc"), + "admin" + ); + + expect(res.status).toBe(400); + }); + + it("devuelve 404 cuando la categoría no existe", async () => { + prisma.productCategories.findUnique.mockResolvedValue(null); + + const res = await asRole( + request(app).get("/api/admin/categories/999"), + "admin" + ); + + expect(res.status).toBe(404); + }); + + it("devuelve 200 con la categoría y productCount", async () => { + prisma.productCategories.findUnique.mockResolvedValue(mockCategory); + + const res = await asRole( + request(app).get("/api/admin/categories/1"), + "admin" + ); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: 1, + name: "Electrónica", + status: true, + productCount: 5 + }); + }); + + it("devuelve la categoría aunque status sea false (admin ve todo)", async () => { + prisma.productCategories.findUnique.mockResolvedValue({ + ...mockCategory, + status: false + }); + + const res = await asRole( + request(app).get("/api/admin/categories/1"), + "admin" + ); + + expect(res.status).toBe(200); + expect(res.body.status).toBe(false); + }); + + it("retorna productCount en 0 cuando la categoría no tiene productos", async () => { + prisma.productCategories.findUnique.mockResolvedValue({ + ...mockCategory, + _count: { products: 0 } + }); + + const res = await asRole( + request(app).get("/api/admin/categories/1"), + "admin" + ); + + expect(res.status).toBe(200); + expect(res.body.productCount).toBe(0); + }); +}); \ No newline at end of file