diff --git a/src/app.js b/src/app.js index c74f5cb..0b5d4dd 100644 --- a/src/app.js +++ b/src/app.js @@ -42,9 +42,10 @@ import { // Rutas de administración import { - adminUsersRoutes, + adminUsersRoutes, adminCategoryRoutes, adminStoresRoutes, + adminProductsRoutes, } from "./modules/admin/index.js"; const app = express(); @@ -96,6 +97,7 @@ app.use("/api/orders", orderRouter); app.use("/api/admin", adminUsersRoutes); app.use("/api/admin/categories", adminCategoryRoutes); app.use("/api/admin/stores", adminStoresRoutes); +app.use("/api/admin/products", adminProductsRoutes); // Rutas de distancias app.use("/api/distances", distanceRoutes); diff --git a/src/modules/admin/index.js b/src/modules/admin/index.js index 378c5a1..5d7039f 100644 --- a/src/modules/admin/index.js +++ b/src/modules/admin/index.js @@ -1,3 +1,4 @@ export * from "./categories/admin-category.routes.js"; export * from "./users/admin-users.routes.js"; -export * from "./stores/admin-stores.routes.js"; \ No newline at end of file +export * from "./stores/admin-stores.routes.js"; +export * from "./products/admin-products.routes.js"; \ No newline at end of file diff --git a/src/modules/admin/products/admin-products.controller.js b/src/modules/admin/products/admin-products.controller.js new file mode 100644 index 0000000..b641001 --- /dev/null +++ b/src/modules/admin/products/admin-products.controller.js @@ -0,0 +1,26 @@ +import { + getProductsAdminService, + updateProductApprovalStatusService, +} from "./admin-products.service.js"; + +export const getProducts = async (req, res, next) => { + try { + const { search, approvalStatus } = req.query; + const result = await getProductsAdminService({ search, approvalStatus }, req.pagination); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const updateProductStatus = async (req, res, next) => { + try { + if (!req.user?.id_user) { + return res.status(401).json({ success: false, message: "Usuario autenticado requerido" }); + } + const result = await updateProductApprovalStatusService(req.params.id, req.body); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/src/modules/admin/products/admin-products.routes.js b/src/modules/admin/products/admin-products.routes.js new file mode 100644 index 0000000..8361615 --- /dev/null +++ b/src/modules/admin/products/admin-products.routes.js @@ -0,0 +1,16 @@ +import { Router } from "express"; +import authenticate from "../../../config/jwt.config.js"; +import { requireRole } from "../../../middlewares/auth.middleware.js"; +import { parsePagination } from "../../../middlewares/pagination.middleware.js"; +import { ROLES } from "../../../utils/contants/roles.constant.js"; +import { getProducts, updateProductStatus } from "./admin-products.controller.js"; + +const router = Router(); + +// GET /api/admin/products?search=&approvalStatus=&page=&limit= +router.get("/", authenticate, requireRole(ROLES.ADMIN), parsePagination, getProducts); + +// PATCH /api/admin/products/:id/status — body: { status: "ACTIVE"|"REJECTED", reason? } +router.patch("/:id/status", authenticate, requireRole(ROLES.ADMIN), updateProductStatus); + +export { router as adminProductsRoutes }; \ No newline at end of file diff --git a/src/modules/admin/products/admin-products.service.js b/src/modules/admin/products/admin-products.service.js new file mode 100644 index 0000000..5163a07 --- /dev/null +++ b/src/modules/admin/products/admin-products.service.js @@ -0,0 +1,173 @@ +import { prisma } from "../../../lib/prisma.js"; +import { NotFoundError, ValidationError } from "../../../lib/errors.js"; + +const VALID_APPROVAL_STATUSES = ["PENDING", "ACTIVE", "REJECTED"]; + +const mapProductResponse = (product) => ({ + id: product.id_product, + name: product.name, + description: product.description, + price: Number(product.price), + visible: product.visible, + approvalStatus: product.approval_status, + rejectionReason: product.rejection_reason ?? null, + category: product.product_category + ? { id: product.product_category.id_product_category, name: product.product_category.name } + : null, + store: product.store + ? { id: product.store.id_store, name: product.store.name } + : null, + reportCount: product._count?.product_reports ?? 0, + latestReport: product.product_reports?.[0] + ? { + reason: product.product_reports[0].reason, + description: product.product_reports[0].description ?? null, + reportedAt: product.product_reports[0].created_at, + } + : null, + createdAt: product.created_at, +}); + +const productSelect = { + id_product: true, + name: true, + description: true, + price: true, + visible: true, + approval_status: true, + rejection_reason: true, + created_at: true, + product_category: { + select: { id_product_category: true, name: true }, + }, + store: { + select: { id_store: true, name: true, fk_user: true }, + }, + product_reports: { + where: { status: true }, + orderBy: { created_at: "desc" }, + take: 1, + select: { reason: true, description: true, created_at: true }, + }, + _count: { + select: { product_reports: { where: { status: true } } }, + }, +}; + +export const getProductsAdminService = async ({ search, approvalStatus }, pagination) => { + const where = { status: true }; + + if (search?.toString().trim()) { + const term = search.toString().trim(); + where.OR = [ + { name: { contains: term, mode: "insensitive" } }, + { store: { name: { contains: term, mode: "insensitive" } } }, + ]; + } + + if (approvalStatus !== undefined) { + const normalized = approvalStatus.toString().trim().toUpperCase(); + if (!VALID_APPROVAL_STATUSES.includes(normalized)) { + throw new ValidationError( + `approvalStatus inválido. Valores permitidos: ${VALID_APPROVAL_STATUSES.join(", ")}` + ); + } + where.approval_status = normalized; + } + + const { page, limit, skip } = pagination; + + const [total, products] = await Promise.all([ + prisma.products.count({ where }), + prisma.products.findMany({ + where, + skip, + take: limit, + orderBy: { created_at: "desc" }, + select: productSelect, + }), + ]); + + return { + data: products.map(mapProductResponse), + pagination: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; +}; + +export const updateProductApprovalStatusService = async (productId, { status, reason }) => { + const resolvedProductId = Number(productId); + if (!Number.isInteger(resolvedProductId) || resolvedProductId <= 0) { + throw new ValidationError("productId debe ser un entero positivo"); + } + + const normalizedStatus = status?.toString().trim().toUpperCase(); + if (!["ACTIVE", "REJECTED"].includes(normalizedStatus)) { + throw new ValidationError("status debe ser ACTIVE o REJECTED"); + } + + if (normalizedStatus === "REJECTED" && !reason?.toString().trim()) { + throw new ValidationError("reason es requerido al rechazar un producto"); + } + + // Necesitamos el producto antes de la transacción para construir el mensaje de notificación + const product = await prisma.products.findFirst({ + where: { id_product: resolvedProductId, status: true }, + select: { + id_product: true, + name: true, + approval_status: true, + store: { select: { fk_user: true, name: true } }, + }, + }); + + if (!product) throw new NotFoundError("Producto no encontrado"); + + const isApproving = normalizedStatus === "ACTIVE"; + const trimmedReason = reason?.toString().trim(); + + const updateData = { + approval_status: normalizedStatus, + visible: isApproving, + rejection_reason: isApproving ? null : trimmedReason, + }; + + // updateMany con condición sobre approval_status cierra la ventana TOCTOU: + // si dos admins ejecutan en paralelo, solo uno logrará el update (count > 0). + const [updateResult] = await prisma.$transaction([ + prisma.products.updateMany({ + where: { + id_product: resolvedProductId, + status: true, + approval_status: { not: normalizedStatus }, + }, + data: updateData, + }), + ...(isApproving + ? [] + : [ + prisma.notifications.create({ + data: { + fk_user: product.store.fk_user, + title: "Producto rechazado", + message: `Tu producto "${product.name}" ha sido rechazado. Motivo: ${trimmedReason}`, + }, + }), + ]), + ]); + + if (updateResult.count === 0) { + throw new ValidationError(`El producto ya tiene el estado ${normalizedStatus}`); + } + + const updated = await prisma.products.findUnique({ + where: { id_product: resolvedProductId }, + select: productSelect, + }); + + return mapProductResponse(updated); +}; \ No newline at end of file diff --git a/tests/unit/admin/admin-products.test.js b/tests/unit/admin/admin-products.test.js new file mode 100644 index 0000000..be5ed5c --- /dev/null +++ b/tests/unit/admin/admin-products.test.js @@ -0,0 +1,392 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { prisma } from "../../../src/lib/prisma.js"; +import { + getProductsAdminService, + updateProductApprovalStatusService, +} from "../../../src/modules/admin/products/admin-products.service.js"; +import { NotFoundError, ValidationError } from "../../../src/lib/errors.js"; + +// ─── MOCK DE PRISMA ────────────────────────────────────────────────────────── + +vi.mock("../../../src/lib/prisma.js", () => ({ + prisma: { + products: { + count: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + notifications: { + create: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +// ─── DATOS DE PRUEBA ───────────────────────────────────────────────────────── + +const mockPagination = { page: 1, limit: 10, skip: 0 }; + +const mockProduct = { + id_product: 1, + name: "iPhone 15 Pro Max", + description: "Nuevo, sellado", + price: 10500000, + visible: false, + approval_status: "PENDING", + rejection_reason: null, + created_at: new Date("2024-01-20"), + product_category: { id_product_category: 1, name: "Electrónicos" }, + store: { id_store: 10, name: "TechStore", fk_user: 5 }, + product_reports: [ + { + reason: "DEFECTIVE", + description: "Precio sospechosamente bajo", + created_at: new Date("2024-01-20"), + }, + ], + _count: { product_reports: 3 }, +}; + +const mockProductApproved = { + ...mockProduct, + approval_status: "ACTIVE", + visible: true, + rejection_reason: null, +}; + +const mockProductRejected = { + ...mockProduct, + approval_status: "REJECTED", + visible: false, + rejection_reason: "Producto falsificado confirmado", +}; + +// ─── getProductsAdminService ───────────────────────────────────────────────── + +describe("getProductsAdminService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna todos los productos cuando no se aplican filtros", async () => { + prisma.products.count.mockResolvedValue(4); + prisma.products.findMany.mockResolvedValue([mockProduct]); + + const result = await getProductsAdminService({}, mockPagination); + + expect(result.data).toHaveLength(1); + expect(result.pagination.total).toBe(4); + }); + + it("retorna estructura correcta con data y pagination", async () => { + prisma.products.count.mockResolvedValue(1); + prisma.products.findMany.mockResolvedValue([mockProduct]); + + const result = await getProductsAdminService({}, mockPagination); + + expect(result).toHaveProperty("data"); + expect(result).toHaveProperty("pagination"); + expect(result.pagination).toMatchObject({ + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }); + }); + + it("mapea correctamente todos los campos del producto", async () => { + prisma.products.count.mockResolvedValue(1); + prisma.products.findMany.mockResolvedValue([mockProduct]); + + const result = await getProductsAdminService({}, mockPagination); + const product = result.data[0]; + + expect(product).toMatchObject({ + id: 1, + name: "iPhone 15 Pro Max", + price: 10500000, + approvalStatus: "PENDING", + rejectionReason: null, + reportCount: 3, + category: { id: 1, name: "Electrónicos" }, + store: { id: 10, name: "TechStore" }, + }); + expect(product.latestReport).toMatchObject({ + reason: "DEFECTIVE", + description: "Precio sospechosamente bajo", + }); + }); + + it("retorna latestReport null cuando el producto no tiene reportes", async () => { + prisma.products.count.mockResolvedValue(1); + prisma.products.findMany.mockResolvedValue([ + { ...mockProduct, product_reports: [], _count: { product_reports: 0 } }, + ]); + + const result = await getProductsAdminService({}, mockPagination); + + expect(result.data[0].latestReport).toBeNull(); + expect(result.data[0].reportCount).toBe(0); + }); + + it("retorna array vacío cuando no hay productos", async () => { + prisma.products.count.mockResolvedValue(0); + prisma.products.findMany.mockResolvedValue([]); + + const result = await getProductsAdminService({}, mockPagination); + + expect(result.data).toEqual([]); + expect(result.pagination.totalPages).toBe(0); + }); + + // ── Filtro search ──────────────────────────────────────────────────────── + + it("aplica filtro OR por nombre de producto y nombre de tienda", async () => { + prisma.products.count.mockResolvedValue(1); + prisma.products.findMany.mockResolvedValue([mockProduct]); + + await getProductsAdminService({ search: "iphone" }, mockPagination); + + expect(prisma.products.count).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { name: { contains: "iphone", mode: "insensitive" } }, + { store: { name: { contains: "iphone", mode: "insensitive" } } }, + ], + }), + }) + ); + }); + + it("ignora search cuando es string vacío o solo espacios", async () => { + prisma.products.count.mockResolvedValue(1); + prisma.products.findMany.mockResolvedValue([mockProduct]); + + await getProductsAdminService({ search: " " }, mockPagination); + + expect(prisma.products.count).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.not.objectContaining({ OR: expect.anything() }), + }) + ); + }); + + // ── Filtro approvalStatus ──────────────────────────────────────────────── + + it("filtra por approvalStatus PENDING correctamente", async () => { + prisma.products.count.mockResolvedValue(2); + prisma.products.findMany.mockResolvedValue([mockProduct]); + + await getProductsAdminService({ approvalStatus: "PENDING" }, mockPagination); + + expect(prisma.products.count).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ approval_status: "PENDING" }), + }) + ); + }); + + it("normaliza approvalStatus a mayúsculas antes de filtrar", async () => { + prisma.products.count.mockResolvedValue(1); + prisma.products.findMany.mockResolvedValue([mockProduct]); + + await getProductsAdminService({ approvalStatus: "pending" }, mockPagination); + + expect(prisma.products.count).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ approval_status: "PENDING" }), + }) + ); + }); + + it("lanza ValidationError cuando approvalStatus no es válido", async () => { + await expect( + getProductsAdminService({ approvalStatus: "SUSPENDIDO" }, mockPagination) + ).rejects.toThrow(ValidationError); + }); + + it("acepta todos los approvalStatus válidos: PENDING, ACTIVE, REJECTED", async () => { + prisma.products.count.mockResolvedValue(0); + prisma.products.findMany.mockResolvedValue([]); + + for (const approvalStatus of ["PENDING", "ACTIVE", "REJECTED"]) { + await expect( + getProductsAdminService({ approvalStatus }, mockPagination) + ).resolves.not.toThrow(); + } + }); + + // ── Paginación ─────────────────────────────────────────────────────────── + + it("calcula totalPages correctamente", async () => { + prisma.products.count.mockResolvedValue(25); + prisma.products.findMany.mockResolvedValue([]); + + const result = await getProductsAdminService({}, { page: 1, limit: 10, skip: 0 }); + + expect(result.pagination.totalPages).toBe(3); + }); + + it("pasa skip y limit correctamente a findMany", async () => { + prisma.products.count.mockResolvedValue(30); + prisma.products.findMany.mockResolvedValue([]); + + await getProductsAdminService({}, { page: 3, limit: 10, skip: 20 }); + + expect(prisma.products.findMany).toHaveBeenCalledWith( + expect.objectContaining({ skip: 20, take: 10 }) + ); + }); + + it("ordena los resultados por created_at descendente", async () => { + prisma.products.count.mockResolvedValue(1); + prisma.products.findMany.mockResolvedValue([mockProduct]); + + await getProductsAdminService({}, mockPagination); + + expect(prisma.products.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { created_at: "desc" } }) + ); + }); +}); + +// ─── updateProductApprovalStatusService ────────────────────────────────────── + +describe("updateProductApprovalStatusService", () => { + beforeEach(() => vi.clearAllMocks()); + + // ── Validaciones de entrada ────────────────────────────────────────────── + + it("lanza ValidationError cuando productId no es un entero positivo", async () => { + await expect( + updateProductApprovalStatusService(-1, { status: "ACTIVE" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando status no es ACTIVE ni REJECTED", async () => { + await expect( + updateProductApprovalStatusService(1, { status: "PENDING" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando se rechaza sin reason", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + + await expect( + updateProductApprovalStatusService(1, { status: "REJECTED", reason: "" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando se rechaza sin enviar reason", async () => { + await expect( + updateProductApprovalStatusService(1, { status: "REJECTED" }) + ).rejects.toThrow(ValidationError); + }); + + // ── Producto no encontrado ─────────────────────────────────────────────── + + it("lanza NotFoundError cuando el producto no existe", async () => { + prisma.products.findFirst.mockResolvedValue(null); + + await expect( + updateProductApprovalStatusService(99, { status: "ACTIVE" }) + ).rejects.toThrow(NotFoundError); + }); + + // ── Estado ya asignado (detectado dentro de la transacción) ───────────── + + it("lanza ValidationError cuando el producto ya tiene el estado ACTIVE", async () => { + prisma.products.findFirst.mockResolvedValue({ + ...mockProduct, + approval_status: "ACTIVE", + }); + prisma.$transaction.mockResolvedValue([{ count: 0 }]); + + await expect( + updateProductApprovalStatusService(1, { status: "ACTIVE" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando el producto ya tiene el estado REJECTED", async () => { + prisma.products.findFirst.mockResolvedValue({ + ...mockProduct, + approval_status: "REJECTED", + }); + prisma.$transaction.mockResolvedValue([{ count: 0 }]); + + await expect( + updateProductApprovalStatusService(1, { status: "REJECTED", reason: "Motivo" }) + ).rejects.toThrow(ValidationError); + }); + + // ── Aprobación ─────────────────────────────────────────────────────────── + + it("aprueba el producto correctamente: visible=true, rejection_reason=null", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.$transaction.mockResolvedValue([{ count: 1 }]); + prisma.products.findUnique.mockResolvedValue(mockProductApproved); + + const result = await updateProductApprovalStatusService(1, { status: "ACTIVE" }); + + expect(prisma.$transaction).toHaveBeenCalled(); + expect(result.approvalStatus).toBe("ACTIVE"); + expect(result.visible).toBe(true); + expect(result.rejectionReason).toBeNull(); + }); + + it("al aprobar NO crea notificación", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.$transaction.mockImplementation(async (ops) => { + // Solo debe haber 1 operación (updateMany), sin notificación + expect(ops).toHaveLength(1); + return [{ count: 1 }]; + }); + prisma.products.findUnique.mockResolvedValue(mockProductApproved); + + await updateProductApprovalStatusService(1, { status: "ACTIVE" }); + }); + + // ── Rechazo ────────────────────────────────────────────────────────────── + + it("rechaza el producto correctamente: visible=false, rejection_reason asignado", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.$transaction.mockResolvedValue([{ count: 1 }, {}]); + prisma.products.findUnique.mockResolvedValue(mockProductRejected); + + const result = await updateProductApprovalStatusService(1, { + status: "REJECTED", + reason: "Producto falsificado confirmado", + }); + + expect(result.approvalStatus).toBe("REJECTED"); + expect(result.visible).toBe(false); + expect(result.rejectionReason).toBe("Producto falsificado confirmado"); + }); + + it("al rechazar crea notificación para el vendedor", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.$transaction.mockImplementation(async (ops) => { + // Debe haber 2 operaciones: updateMany + notification + expect(ops).toHaveLength(2); + return [{ count: 1 }, {}]; + }); + prisma.products.findUnique.mockResolvedValue(mockProductRejected); + + await updateProductApprovalStatusService(1, { + status: "REJECTED", + reason: "Producto falsificado", + }); + }); + + it("normaliza status a mayúsculas antes de procesar", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.$transaction.mockResolvedValue([{ count: 1 }]); + prisma.products.findUnique.mockResolvedValue(mockProductApproved); + + await expect( + updateProductApprovalStatusService(1, { status: "active" }) + ).resolves.not.toThrow(); + }); +}); \ No newline at end of file