From 36d6c21d5808f6ad292c14b491912e59abdfbf04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20D=C3=ADaz?= Date: Tue, 21 Apr 2026 14:36:00 -0230 Subject: [PATCH 1/3] =?UTF-8?q?feat(admin):=20implementar=20moderaci=C3=B3?= =?UTF-8?q?n=20de=20productos=20para=20panel=20de=20administraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contexto: - Primera funcionalidad de moderación de contenido del panel admin - Los productos tienen approval_status (PENDING/ACTIVE/REJECTED) y visible - Al aprobar: producto visible en catálogo público - Al rechazar: producto oculto + notificación automática al vendedor - Sigue el mismo patrón de aprobación/rechazo que ya existía en admin/stores Endpoints: - GET /api/admin/products — lista productos con filtros y paginación - PATCH /api/admin/products/:id/status — aprueba o rechaza un producto Filtros del GET: - search: busca por nombre de producto o nombre de tienda (case insensitive) - approvalStatus: PENDING | ACTIVE | REJECTED - page + limit: paginación vía parsePagination middleware Lógica del PATCH: - ACTIVE: approval_status=ACTIVE, visible=true, rejection_reason=null - REJECTED: approval_status=REJECTED, visible=false, rejection_reason=reason requerido - Validación de estado idempotente (evita re-aprobar o re-rechazar) - Notificación al vendedor creada en la misma transacción al rechazar --- src/app.js | 4 +- src/modules/admin/index.js | 3 +- .../products/admin-products.controller.js | 30 ++ .../admin/products/admin-products.routes.js | 16 + .../admin/products/admin-products.service.js | 166 ++++++++ tests/unit/admin/admin-products.test.js | 383 ++++++++++++++++++ 6 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 src/modules/admin/products/admin-products.controller.js create mode 100644 src/modules/admin/products/admin-products.routes.js create mode 100644 src/modules/admin/products/admin-products.service.js create mode 100644 tests/unit/admin/admin-products.test.js 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..fd0633e --- /dev/null +++ b/src/modules/admin/products/admin-products.controller.js @@ -0,0 +1,30 @@ +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.user.id_user, + 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..5b5cf0f --- /dev/null +++ b/src/modules/admin/products/admin-products.service.js @@ -0,0 +1,166 @@ +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 (adminUserId, 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"); + } + + 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"); + } + + if (product.approval_status === normalizedStatus) { + throw new ValidationError( + `El producto ya tiene el estado ${normalizedStatus}` + ); + } + + const isApproving = normalizedStatus === "ACTIVE"; + + const updateData = { + approval_status: normalizedStatus, + visible: isApproving, + rejection_reason: isApproving ? null : reason.toString().trim(), + }; + + const [updated] = await prisma.$transaction([ + prisma.products.update({ + where: { id_product: resolvedProductId }, + data: updateData, + select: productSelect, + }), + // Notificar al vendedor solo al rechazar + ...(isApproving + ? [] + : [ + prisma.notifications.create({ + data: { + fk_user: product.store.fk_user, + title: "Producto rechazado", + message: `Tu producto "${product.name}" ha sido rechazado. Motivo: ${reason.toString().trim()}`, + }, + }), + ]), + ]); + + 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..89d4868 --- /dev/null +++ b/tests/unit/admin/admin-products.test.js @@ -0,0 +1,383 @@ +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(), + update: 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, -1, { status: "ACTIVE" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando status no es ACTIVE ni REJECTED", async () => { + await expect( + updateProductApprovalStatusService(1, 1, { status: "PENDING" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando se rechaza sin reason", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + + await expect( + updateProductApprovalStatusService(1, 1, { status: "REJECTED", reason: "" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando se rechaza sin enviar reason", async () => { + await expect( + updateProductApprovalStatusService(1, 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(1, 99, { status: "ACTIVE" }) + ).rejects.toThrow(NotFoundError); + }); + + // ── Estado ya asignado ─────────────────────────────────────────────────── + + it("lanza ValidationError cuando el producto ya tiene el estado ACTIVE", async () => { + prisma.products.findFirst.mockResolvedValue({ + ...mockProduct, + approval_status: "ACTIVE", + }); + + await expect( + updateProductApprovalStatusService(1, 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", + }); + + await expect( + updateProductApprovalStatusService(1, 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([mockProductApproved]); + + const result = await updateProductApprovalStatusService(1, 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 (update), sin notificación + expect(ops).toHaveLength(1); + return [mockProductApproved]; + }); + + await updateProductApprovalStatusService(1, 1, { status: "ACTIVE" }); + }); + + // ── Rechazo ────────────────────────────────────────────────────────────── + + it("rechaza el producto correctamente: visible=false, rejection_reason asignado", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.$transaction.mockResolvedValue([mockProductRejected]); + + const result = await updateProductApprovalStatusService(1, 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: update + notification + expect(ops).toHaveLength(2); + return [mockProductRejected]; + }); + + await updateProductApprovalStatusService(1, 1, { + status: "REJECTED", + reason: "Producto falsificado", + }); + }); + + it("normaliza status a mayúsculas antes de procesar", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.$transaction.mockResolvedValue([mockProductApproved]); + + await expect( + updateProductApprovalStatusService(1, 1, { status: "active" }) + ).resolves.not.toThrow(); + }); +}); \ No newline at end of file From a2aeff6cfc91eb84f64cb9c616e620a6279e7bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20D=C3=ADaz?= Date: Tue, 21 Apr 2026 15:45:35 -0230 Subject: [PATCH 2/3] =?UTF-8?q?OM-467(reportes):=20implementar=20reporte?= =?UTF-8?q?=20de=20producto=20para=20clientes=20y=20resoluci=C3=B3n=20para?= =?UTF-8?q?=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contexto: El módulo de reportes de productos ya existía con funcionalidades para Admin y Seller (listar, filtrar, actualizar estado). Este ticket extiende ese módulo con las capacidades del lado del cliente y del administrador para cerrar el ciclo completo. Cambios en product-report.service.js: - Se agrega PRODUCT_REPORT_REASONS: catálogo exportado con { value, label } en español. Es la fuente única de verdad para los motivos válidos: usado en la validación de createProductReportService y expuesto vía el endpoint /reasons para el frontend. - getProductReportReasonsService: devuelve el catálogo sin lógica adicional ni DB. - createProductReportService: cliente crea un reporte de producto. Valida productId (entero positivo), reason contra el catálogo, verifica que el producto exista y esté activo (status=true), y previene duplicados lanzando ConflictError si ya existe un reporte PENDING del mismo usuario para el mismo producto. - checkProductReportService: cliente consulta si ya tiene un reporte PENDING para un producto. Retorna { hasReport: boolean, reportId: number | null }. - resolveProductReportAdminService: admin cierra un reporte como RESOLVED o REJECTED. Requiere notes obligatorias y no permite modificar reportes ya cerrados. Cambios en product-report.controller.js: - getProductReportReasons: controlador síncrono (sin try/catch ni DB) para el catálogo. - createProductReport: 201 Created al crear el reporte. - checkProductReport: lee productId desde query params. - resolveProductReport: lee reportId desde params y { status, notes } desde body. Cambios en product-report.routes.js: - GET /api/reports/products/reasons → sin auth, catálogo para el frontend - POST /api/reports/products → requireRole(CUSTOMER) - GET /api/reports/products/check?productId= → requireRole(CUSTOMER) - PATCH /api/reports/products/:reportId/resolve → requireRole(ADMIN) Tests (product-report.test.js): - Se actualiza el mock de Prisma agregando products.findFirst y productReports.create. - getProductReportReasonsService: 4 tests. - createProductReportService: 9 tests. - checkProductReportService: 3 tests. - resolveProductReportAdminService: 12 tests. Total: 67 tests pasando. --- .../product/product-report.controller.js | 55 ++- .../reports/product/product-report.routes.js | 23 +- .../reports/product/product-report.service.js | 146 +++++++- tests/unit/report/product-report.test.js | 351 +++++++++++++++++- 4 files changed, 568 insertions(+), 7 deletions(-) diff --git a/src/modules/global/reports/product/product-report.controller.js b/src/modules/global/reports/product/product-report.controller.js index 95153e7..b335650 100644 --- a/src/modules/global/reports/product/product-report.controller.js +++ b/src/modules/global/reports/product/product-report.controller.js @@ -1,4 +1,12 @@ -import { getProductsReportsService, updateProductReportService, getProductsReportsFilteredService } from "./product-report.service.js"; +import { + getProductsReportsService, + updateProductReportService, + getProductsReportsFilteredService, + getProductReportReasonsService, + createProductReportService, + checkProductReportService, + resolveProductReportAdminService, +} from "./product-report.service.js"; /** * GET /api/reports/products @@ -68,4 +76,47 @@ export const getProductsReportsFiltered = async (req, res, next) => { } catch (error) { next(error); } -} \ No newline at end of file +} + +export const getProductReportReasons = (_req, res) => { + return res.status(200).json(getProductReportReasonsService()); +}; + +export const createProductReport = async (req, res, next) => { + try { + if (!req.user?.id_user) { + return res.status(401).json({ success: false, message: "Usuario autenticado requerido" }); + } + const { productId, reason, description } = req.body; + const report = await createProductReportService(req.user.id_user, { productId, reason, description }); + return res.status(201).json(report); + } catch (error) { + next(error); + } +}; + +export const checkProductReport = async (req, res, next) => { + try { + if (!req.user?.id_user) { + return res.status(401).json({ success: false, message: "Usuario autenticado requerido" }); + } + const { productId } = req.query; + const result = await checkProductReportService(req.user.id_user, productId); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const resolveProductReport = async (req, res, next) => { + try { + if (!req.user?.id_user) { + return res.status(401).json({ success: false, message: "Usuario autenticado requerido" }); + } + const { status, notes } = req.body; + const result = await resolveProductReportAdminService(req.user.id_user, req.params.reportId, { status, notes }); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/src/modules/global/reports/product/product-report.routes.js b/src/modules/global/reports/product/product-report.routes.js index 269ce05..064e186 100644 --- a/src/modules/global/reports/product/product-report.routes.js +++ b/src/modules/global/reports/product/product-report.routes.js @@ -1,7 +1,17 @@ import { Router } from "express"; import authenticate from "../../../../config/jwt.config.js"; +import { requireRole } from "../../../../middlewares/auth.middleware.js"; +import { ROLES } from "../../../../utils/contants/roles.constant.js"; import { parsePagination } from "../../../../middlewares/pagination.middleware.js"; -import { getProductsReports, updateProductReport, getProductsReportsFiltered } from "./product-report.controller.js"; +import { + getProductsReports, + updateProductReport, + getProductsReportsFiltered, + getProductReportReasons, + createProductReport, + checkProductReport, + resolveProductReport, +} from "./product-report.controller.js"; const router = Router({ mergeParams: true }); @@ -184,5 +194,16 @@ router.get("/products/filtered", authenticate, parsePagination, getProductsRepor */ router.put("/products/:reportId", authenticate, updateProductReport); +// GET /api/reports/products/reasons — Catálogo de motivos (sin auth, usado por el frontend para poblar el select) +router.get("/products/reasons", getProductReportReasons); + +// POST /api/reports/products — Cliente reporta un producto +router.post("/products", authenticate, requireRole(ROLES.CUSTOMER), createProductReport); + +// GET /api/reports/products/check?productId= — Cliente verifica si ya reportó un producto +router.get("/products/check", authenticate, requireRole(ROLES.CUSTOMER), checkProductReport); + +// PATCH /api/reports/products/:reportId/resolve — Admin resuelve o rechaza un reporte +router.patch("/products/:reportId/resolve", authenticate, requireRole(ROLES.ADMIN), resolveProductReport); export default router; \ No newline at end of file diff --git a/src/modules/global/reports/product/product-report.service.js b/src/modules/global/reports/product/product-report.service.js index 5a7d4cd..61a38cb 100644 --- a/src/modules/global/reports/product/product-report.service.js +++ b/src/modules/global/reports/product/product-report.service.js @@ -1,11 +1,22 @@ import { prisma } from "../../../../lib/prisma.js"; import { + ConflictError, ForbiddenError, NotFoundError, ValidationError } from "../../../../lib/errors.js"; import { parsePositiveInteger } from "../../../../lib/validators.js"; +// Catálogo de motivos de reporte. Es la fuente única de verdad: +// se usa para validar en createProductReportService y se expone vía GET /reasons para el frontend. +export const PRODUCT_REPORT_REASONS = [ + { value: "DEFECTIVE", label: "Producto en mal estado" }, + { value: "WRONG_ITEM", label: "Producto incorrecto o no es lo que pedí" }, + { value: "MISSING_ITEM", label: "Producto incompleto o faltante" }, + { value: "LATE_DELIVERY", label: "Entrega tardía o no recibida" }, + { value: "CUSTOMER_SERVICE", label: "Mala atención del vendedor" }, + { value: "OTHER", label: "Otro" }, +]; const BASE_QUERY = { where: { status: true }, @@ -92,7 +103,7 @@ export const getProductsReportsService = async (authenticatedUserId) => { }; -// Solo el Seller puede actualizar el estado de un reporte de producto (IN_PROGRESS o RESOLVED) +// Solo el Seller puede actualizar el estado de un reporte de producto (IN_PROGRESS o RESOLVED) // y agregar notas explicativas. No puede modificar un reporte ya resuelto o rechazado. export const updateProductReportService = async (authenticatedUserId, reportId, { report_status, notes }) => { const resolvedUserId = parsePositiveInteger(authenticatedUserId, "userId"); @@ -205,7 +216,81 @@ export const updateProductReportService = async (authenticatedUserId, reportId, return updatedReport; }; -// Endpoint para que Admin y Seller puedan obtener reportes de productos filtrados por estado del reporte +// Devuelve el catálogo de motivos válidos con sus etiquetas en español. +export const getProductReportReasonsService = () => PRODUCT_REPORT_REASONS; + +// Cliente crea un reporte de producto. No puede duplicar un reporte PENDING para el mismo producto. +export const createProductReportService = async (authenticatedUserId, { productId, reason, description }) => { + const resolvedUserId = parsePositiveInteger(authenticatedUserId, "userId"); + const resolvedProductId = parsePositiveInteger(productId, "productId"); + + if (!reason || typeof reason !== "string" || !reason.toString().trim()) { + throw new ValidationError("La razón del reporte es requerida"); + } + + const normalizedReason = reason.toString().trim().toUpperCase(); + const allowedValues = PRODUCT_REPORT_REASONS.map((r) => r.value); + if (!allowedValues.includes(normalizedReason)) { + throw new ValidationError(`Razón inválida. Los valores permitidos son: ${allowedValues.join(", ")}`); + } + + const product = await prisma.products.findFirst({ + where: { id_product: resolvedProductId, status: true }, + select: { id_product: true }, + }); + if (!product) throw new NotFoundError("Producto no encontrado"); + + const existing = await prisma.productReports.findFirst({ + where: { + fk_product: resolvedProductId, + fk_reporter: resolvedUserId, + report_status: "PENDING", + status: true, + }, + select: { id_product_report: true }, + }); + if (existing) throw new ConflictError("Ya tenés un reporte pendiente para este producto"); + + const trimmedDescription = description?.toString().trim() || null; + + const report = await prisma.productReports.create({ + data: { + fk_product: resolvedProductId, + fk_reporter: resolvedUserId, + reason: normalizedReason, + description: trimmedDescription, + }, + select: { + id_product_report: true, + reason: true, + description: true, + report_status: true, + created_at: true, + }, + }); + + return report; +}; + +// Cliente verifica si ya tiene un reporte PENDING para un producto dado. +export const checkProductReportService = async (authenticatedUserId, productId) => { + const resolvedUserId = parsePositiveInteger(authenticatedUserId, "userId"); + const resolvedProductId = parsePositiveInteger(productId, "productId"); + + const existing = await prisma.productReports.findFirst({ + where: { + fk_product: resolvedProductId, + fk_reporter: resolvedUserId, + report_status: "PENDING", + status: true, + }, + select: { id_product_report: true }, + }); + + return { hasReport: !!existing, reportId: existing?.id_product_report ?? null }; +}; + +// Endpoint para que Admin y Seller puedan obtener reportes de productos filtrados por estado del reporte // y búsqueda por nombre del cliente que reportó. El Seller solo ve los reportes de su tienda. export const getProductsReportsFilteredService = async (authenticatedUserId, { report_status, search }, { page, limit, skip }) => { const resolvedUserId = parsePositiveInteger(authenticatedUserId, "userId"); @@ -273,4 +358,61 @@ export const getProductsReportsFilteredService = async (authenticatedUserId, { r total_pages: Math.ceil(total / limit), }, }; +}; + +// Admin resuelve o rechaza un reporte de producto. +export const resolveProductReportAdminService = async (adminUserId, reportId, { status, notes }) => { + const resolvedAdminId = parsePositiveInteger(adminUserId, "userId"); + const resolvedReportId = parsePositiveInteger(reportId, "reportId"); + + if (!status || typeof status !== "string" || !status.toString().trim()) { + throw new ValidationError("El estado es requerido"); + } + + const normalizedStatus = status.toString().trim().toUpperCase(); + const allowedStatuses = ["RESOLVED", "REJECTED"]; + if (!allowedStatuses.includes(normalizedStatus)) { + throw new ValidationError(`Estado inválido. Los valores permitidos son: ${allowedStatuses.join(", ")}`); + } + + const trimmedNotes = notes?.toString().trim(); + if (!trimmedNotes) { + throw new ValidationError("Las notas son obligatorias al resolver o rechazar un reporte"); + } + + const report = await prisma.productReports.findFirst({ + where: { id_product_report: resolvedReportId, status: true }, + select: { id_product_report: true, report_status: true }, + }); + + if (!report) throw new NotFoundError("Reporte no encontrado"); + + if (report.report_status === "RESOLVED" || report.report_status === "REJECTED") { + throw new ValidationError("Este reporte ya fue cerrado y no puede modificarse"); + } + + let updated; + try { + updated = await prisma.productReports.update({ + where: { id_product_report: resolvedReportId, status: true }, + data: { + report_status: normalizedStatus, + notes: trimmedNotes, + resolved_by: resolvedAdminId, + resolved_at: new Date(), + }, + select: { + id_product_report: true, + report_status: true, + notes: true, + resolved_at: true, + resolver: { select: { id_user: true, name: true } }, + }, + }); + } catch (e) { + if (e?.code === "P2025") throw new NotFoundError("El reporte ya no está disponible o fue modificado por otro proceso"); + throw e; + } + + return updated; }; \ No newline at end of file diff --git a/tests/unit/report/product-report.test.js b/tests/unit/report/product-report.test.js index 5718dd6..70b3dd9 100644 --- a/tests/unit/report/product-report.test.js +++ b/tests/unit/report/product-report.test.js @@ -4,8 +4,14 @@ import { getProductsReportsService, updateProductReportService, getProductsReportsFilteredService, + getProductReportReasonsService, + createProductReportService, + checkProductReportService, + resolveProductReportAdminService, + PRODUCT_REPORT_REASONS, } from "../../../src/modules/global/reports/product/product-report.service.js"; import { + ConflictError, ForbiddenError, NotFoundError, ValidationError, @@ -21,9 +27,14 @@ vi.mock("../../../src/lib/prisma.js", () => ({ stores: { findUnique: vi.fn(), }, + products: { + findFirst: vi.fn(), + }, productReports: { findMany: vi.fn(), findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), update: vi.fn(), count: vi.fn(), }, @@ -38,6 +49,8 @@ const mockCustomerUser = { role: "CUSTOMER" }; const mockStore = { id_store: 10 }; +const mockProduct = { id_product: 3 }; + const mockReport = { id_product_report: 1, reason: "Producto en mal estado", @@ -493,7 +506,6 @@ describe("getProductsReportsFilteredService", () => { const result = await getProductsReportsFilteredService(1, {}, mockPagination); expect(result.data).toHaveLength(1); - // Verifica que findMany fue llamado (el filtro de tienda se aplica internamente) expect(prisma.productReports.findMany).toHaveBeenCalled(); }); @@ -504,7 +516,6 @@ describe("getProductsReportsFilteredService", () => { await getProductsReportsFilteredService(1, {}, mockPagination); - // Ambos deben haber sido llamados exactamente una vez expect(prisma.productReports.findMany).toHaveBeenCalledTimes(1); expect(prisma.productReports.count).toHaveBeenCalledTimes(1); }); @@ -514,4 +525,340 @@ describe("getProductsReportsFilteredService", () => { getProductsReportsFilteredService(-1, {}, mockPagination) ).rejects.toThrow(ValidationError); }); +}); + +// ─── getProductReportReasonsService ────────────────────────────────────────── + +describe("getProductReportReasonsService", () => { + it("retorna un array con al menos un elemento", () => { + const result = getProductReportReasonsService(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it("cada elemento tiene las propiedades value y label", () => { + const result = getProductReportReasonsService(); + result.forEach((item) => { + expect(item).toHaveProperty("value"); + expect(item).toHaveProperty("label"); + expect(typeof item.value).toBe("string"); + expect(typeof item.label).toBe("string"); + }); + }); + + it("retorna exactamente el catálogo exportado PRODUCT_REPORT_REASONS", () => { + const result = getProductReportReasonsService(); + expect(result).toBe(PRODUCT_REPORT_REASONS); + }); + + it("incluye el motivo OTHER como último elemento", () => { + const result = getProductReportReasonsService(); + const last = result[result.length - 1]; + expect(last.value).toBe("OTHER"); + }); +}); + +// ─── createProductReportService ─────────────────────────────────────────────── + +describe("createProductReportService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza ValidationError cuando productId no es un entero positivo", async () => { + await expect( + createProductReportService(1, { productId: -1, reason: "DEFECTIVE" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando reason está vacío", async () => { + await expect( + createProductReportService(1, { productId: 3, reason: "" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando reason no es válida", async () => { + await expect( + createProductReportService(1, { productId: 3, reason: "INVALIDA" }) + ).rejects.toThrow(ValidationError); + }); + + it("acepta todas las razones del catálogo PRODUCT_REPORT_REASONS", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.productReports.findFirst.mockResolvedValue(null); + prisma.productReports.create.mockResolvedValue({ + id_product_report: 1, + reason: "DEFECTIVE", + description: null, + report_status: "PENDING", + created_at: new Date(), + }); + + for (const { value } of PRODUCT_REPORT_REASONS) { + await expect( + createProductReportService(1, { productId: 3, reason: value }) + ).resolves.toBeDefined(); + } + }); + + it("normaliza reason a mayúsculas antes de procesar", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.productReports.findFirst.mockResolvedValue(null); + prisma.productReports.create.mockResolvedValue({ + id_product_report: 1, + reason: "DEFECTIVE", + description: null, + report_status: "PENDING", + created_at: new Date(), + }); + + await expect( + createProductReportService(1, { productId: 3, reason: "defective" }) + ).resolves.not.toThrow(); + }); + + it("lanza NotFoundError cuando el producto no existe o está inactivo", async () => { + prisma.products.findFirst.mockResolvedValue(null); + + await expect( + createProductReportService(1, { productId: 99, reason: "DEFECTIVE" }) + ).rejects.toThrow(NotFoundError); + }); + + it("lanza ConflictError cuando ya existe un reporte PENDING para el mismo producto", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.productReports.findFirst.mockResolvedValue({ id_product_report: 1 }); + + await expect( + createProductReportService(1, { productId: 3, reason: "DEFECTIVE" }) + ).rejects.toThrow(ConflictError); + }); + + it("crea el reporte correctamente con status PENDING por defecto", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.productReports.findFirst.mockResolvedValue(null); + const createdReport = { + id_product_report: 5, + reason: "DEFECTIVE", + description: "Llegó roto", + report_status: "PENDING", + created_at: new Date(), + }; + prisma.productReports.create.mockResolvedValue(createdReport); + + const result = await createProductReportService(1, { + productId: 3, + reason: "DEFECTIVE", + description: "Llegó roto", + }); + + expect(result.report_status).toBe("PENDING"); + expect(result.reason).toBe("DEFECTIVE"); + expect(result.description).toBe("Llegó roto"); + }); + + it("crea el reporte con description null cuando no se envía", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.productReports.findFirst.mockResolvedValue(null); + prisma.productReports.create.mockResolvedValue({ + id_product_report: 5, + reason: "OTHER", + description: null, + report_status: "PENDING", + created_at: new Date(), + }); + + const result = await createProductReportService(1, { productId: 3, reason: "OTHER" }); + + expect(prisma.productReports.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ description: null }), + }) + ); + expect(result.description).toBeNull(); + }); + + it("asigna fk_reporter con el userId autenticado", async () => { + prisma.products.findFirst.mockResolvedValue(mockProduct); + prisma.productReports.findFirst.mockResolvedValue(null); + prisma.productReports.create.mockResolvedValue({ + id_product_report: 5, + reason: "DEFECTIVE", + description: null, + report_status: "PENDING", + created_at: new Date(), + }); + + await createProductReportService(7, { productId: 3, reason: "DEFECTIVE" }); + + expect(prisma.productReports.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ fk_reporter: 7, fk_product: 3 }), + }) + ); + }); +}); + +// ─── checkProductReportService ──────────────────────────────────────────────── + +describe("checkProductReportService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza ValidationError cuando productId no es un entero positivo", async () => { + await expect( + checkProductReportService(1, -1) + ).rejects.toThrow(ValidationError); + }); + + it("retorna hasReport=false cuando no existe reporte PENDING", async () => { + prisma.productReports.findFirst.mockResolvedValue(null); + + const result = await checkProductReportService(1, 3); + + expect(result.hasReport).toBe(false); + expect(result.reportId).toBeNull(); + }); + + it("retorna hasReport=true con el reportId cuando existe un reporte PENDING", async () => { + prisma.productReports.findFirst.mockResolvedValue({ id_product_report: 5 }); + + const result = await checkProductReportService(1, 3); + + expect(result.hasReport).toBe(true); + expect(result.reportId).toBe(5); + }); +}); + +// ─── resolveProductReportAdminService ───────────────────────────────────────── + +describe("resolveProductReportAdminService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza ValidationError cuando reportId no es un entero positivo", async () => { + await expect( + resolveProductReportAdminService(1, -1, { status: "RESOLVED", notes: "OK" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando status está vacío", async () => { + await expect( + resolveProductReportAdminService(1, 1, { status: "", notes: "OK" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando status no es RESOLVED ni REJECTED", async () => { + await expect( + resolveProductReportAdminService(1, 1, { status: "IN_PROGRESS", notes: "OK" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando notes no se envía", async () => { + await expect( + resolveProductReportAdminService(1, 1, { status: "RESOLVED" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando notes está vacío", async () => { + await expect( + resolveProductReportAdminService(1, 1, { status: "RESOLVED", notes: " " }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza NotFoundError cuando el reporte no existe", async () => { + prisma.productReports.findFirst.mockResolvedValue(null); + + await expect( + resolveProductReportAdminService(1, 99, { status: "RESOLVED", notes: "OK" }) + ).rejects.toThrow(NotFoundError); + }); + + it("lanza ValidationError cuando el reporte ya está RESOLVED", async () => { + prisma.productReports.findFirst.mockResolvedValue({ id_product_report: 1, report_status: "RESOLVED" }); + + await expect( + resolveProductReportAdminService(1, 1, { status: "REJECTED", notes: "OK" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando el reporte ya está REJECTED", async () => { + prisma.productReports.findFirst.mockResolvedValue({ id_product_report: 1, report_status: "REJECTED" }); + + await expect( + resolveProductReportAdminService(1, 1, { status: "RESOLVED", notes: "OK" }) + ).rejects.toThrow(ValidationError); + }); + + it("resuelve el reporte correctamente con status RESOLVED", async () => { + prisma.productReports.findFirst.mockResolvedValue({ id_product_report: 1, report_status: "PENDING" }); + prisma.productReports.update.mockResolvedValue({ + id_product_report: 1, + report_status: "RESOLVED", + notes: "Verificado y resuelto", + resolved_at: new Date(), + resolver: { id_user: 1, name: "Admin" }, + }); + + const result = await resolveProductReportAdminService(1, 1, { + status: "RESOLVED", + notes: "Verificado y resuelto", + }); + + expect(result.report_status).toBe("RESOLVED"); + expect(result.notes).toBe("Verificado y resuelto"); + expect(result.resolved_at).toBeDefined(); + }); + + it("rechaza el reporte correctamente con status REJECTED", async () => { + prisma.productReports.findFirst.mockResolvedValue({ id_product_report: 1, report_status: "IN_PROGRESS" }); + prisma.productReports.update.mockResolvedValue({ + id_product_report: 1, + report_status: "REJECTED", + notes: "No procede el reclamo", + resolved_at: new Date(), + resolver: { id_user: 1, name: "Admin" }, + }); + + const result = await resolveProductReportAdminService(1, 1, { + status: "REJECTED", + notes: "No procede el reclamo", + }); + + expect(result.report_status).toBe("REJECTED"); + expect(result.notes).toBe("No procede el reclamo"); + }); + + it("normaliza status a mayúsculas antes de procesar", async () => { + prisma.productReports.findFirst.mockResolvedValue({ id_product_report: 1, report_status: "PENDING" }); + prisma.productReports.update.mockResolvedValue({ + id_product_report: 1, + report_status: "RESOLVED", + notes: "OK", + resolved_at: new Date(), + resolver: { id_user: 1, name: "Admin" }, + }); + + await expect( + resolveProductReportAdminService(1, 1, { status: "resolved", notes: "OK" }) + ).resolves.not.toThrow(); + }); + + it("asigna resolved_by con el adminUserId y resolved_at con la fecha actual", async () => { + prisma.productReports.findFirst.mockResolvedValue({ id_product_report: 1, report_status: "PENDING" }); + prisma.productReports.update.mockResolvedValue({ + id_product_report: 1, + report_status: "RESOLVED", + notes: "OK", + resolved_at: new Date(), + resolver: { id_user: 99, name: "Admin Principal" }, + }); + + await resolveProductReportAdminService(99, 1, { status: "RESOLVED", notes: "OK" }); + + expect(prisma.productReports.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + resolved_by: 99, + resolved_at: expect.any(Date), + }), + }) + ); + }); }); \ No newline at end of file From f5d2f764edd2cf4528d89efc1b8fd8279f9652ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20D=C3=ADaz?= Date: Tue, 21 Apr 2026 16:10:02 -0230 Subject: [PATCH 3/3] =?UTF-8?q?OM-308(admin):=20ajustes=20de=20Code=20Rabb?= =?UTF-8?q?it=20=E2=80=94=20eliminar=20adminUserId=20y=20cerrar=20TOCTOU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../products/admin-products.controller.js | 6 +-- .../admin/products/admin-products.service.js | 41 +++++++++------- tests/unit/admin/admin-products.test.js | 49 +++++++++++-------- 3 files changed, 54 insertions(+), 42 deletions(-) diff --git a/src/modules/admin/products/admin-products.controller.js b/src/modules/admin/products/admin-products.controller.js index fd0633e..b641001 100644 --- a/src/modules/admin/products/admin-products.controller.js +++ b/src/modules/admin/products/admin-products.controller.js @@ -18,11 +18,7 @@ export const updateProductStatus = async (req, res, next) => { if (!req.user?.id_user) { return res.status(401).json({ success: false, message: "Usuario autenticado requerido" }); } - const result = await updateProductApprovalStatusService( - req.user.id_user, - req.params.id, - req.body - ); + const result = await updateProductApprovalStatusService(req.params.id, req.body); return res.status(200).json(result); } catch (error) { next(error); diff --git a/src/modules/admin/products/admin-products.service.js b/src/modules/admin/products/admin-products.service.js index 5b5cf0f..5163a07 100644 --- a/src/modules/admin/products/admin-products.service.js +++ b/src/modules/admin/products/admin-products.service.js @@ -99,7 +99,7 @@ export const getProductsAdminService = async ({ search, approvalStatus }, pagina }; }; -export const updateProductApprovalStatusService = async (adminUserId, productId, { status, reason }) => { +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"); @@ -114,6 +114,7 @@ export const updateProductApprovalStatusService = async (adminUserId, productId, 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: { @@ -124,31 +125,28 @@ export const updateProductApprovalStatusService = async (adminUserId, productId, }, }); - if (!product) { - throw new NotFoundError("Producto no encontrado"); - } - - if (product.approval_status === normalizedStatus) { - throw new ValidationError( - `El producto ya tiene el estado ${normalizedStatus}` - ); - } + 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 : reason.toString().trim(), + rejection_reason: isApproving ? null : trimmedReason, }; - const [updated] = await prisma.$transaction([ - prisma.products.update({ - where: { id_product: resolvedProductId }, + // 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, - select: productSelect, }), - // Notificar al vendedor solo al rechazar ...(isApproving ? [] : [ @@ -156,11 +154,20 @@ export const updateProductApprovalStatusService = async (adminUserId, productId, data: { fk_user: product.store.fk_user, title: "Producto rechazado", - message: `Tu producto "${product.name}" ha sido rechazado. Motivo: ${reason.toString().trim()}`, + 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 index 89d4868..be5ed5c 100644 --- a/tests/unit/admin/admin-products.test.js +++ b/tests/unit/admin/admin-products.test.js @@ -14,7 +14,9 @@ vi.mock("../../../src/lib/prisma.js", () => ({ count: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), + findUnique: vi.fn(), update: vi.fn(), + updateMany: vi.fn(), }, notifications: { create: vi.fn(), @@ -259,13 +261,13 @@ describe("updateProductApprovalStatusService", () => { it("lanza ValidationError cuando productId no es un entero positivo", async () => { await expect( - updateProductApprovalStatusService(1, -1, { status: "ACTIVE" }) + updateProductApprovalStatusService(-1, { status: "ACTIVE" }) ).rejects.toThrow(ValidationError); }); it("lanza ValidationError cuando status no es ACTIVE ni REJECTED", async () => { await expect( - updateProductApprovalStatusService(1, 1, { status: "PENDING" }) + updateProductApprovalStatusService(1, { status: "PENDING" }) ).rejects.toThrow(ValidationError); }); @@ -273,13 +275,13 @@ describe("updateProductApprovalStatusService", () => { prisma.products.findFirst.mockResolvedValue(mockProduct); await expect( - updateProductApprovalStatusService(1, 1, { status: "REJECTED", reason: "" }) + updateProductApprovalStatusService(1, { status: "REJECTED", reason: "" }) ).rejects.toThrow(ValidationError); }); it("lanza ValidationError cuando se rechaza sin enviar reason", async () => { await expect( - updateProductApprovalStatusService(1, 1, { status: "REJECTED" }) + updateProductApprovalStatusService(1, { status: "REJECTED" }) ).rejects.toThrow(ValidationError); }); @@ -289,20 +291,21 @@ describe("updateProductApprovalStatusService", () => { prisma.products.findFirst.mockResolvedValue(null); await expect( - updateProductApprovalStatusService(1, 99, { status: "ACTIVE" }) + updateProductApprovalStatusService(99, { status: "ACTIVE" }) ).rejects.toThrow(NotFoundError); }); - // ── Estado ya asignado ─────────────────────────────────────────────────── + // ── 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, 1, { status: "ACTIVE" }) + updateProductApprovalStatusService(1, { status: "ACTIVE" }) ).rejects.toThrow(ValidationError); }); @@ -311,9 +314,10 @@ describe("updateProductApprovalStatusService", () => { ...mockProduct, approval_status: "REJECTED", }); + prisma.$transaction.mockResolvedValue([{ count: 0 }]); await expect( - updateProductApprovalStatusService(1, 1, { status: "REJECTED", reason: "Motivo" }) + updateProductApprovalStatusService(1, { status: "REJECTED", reason: "Motivo" }) ).rejects.toThrow(ValidationError); }); @@ -321,9 +325,10 @@ describe("updateProductApprovalStatusService", () => { it("aprueba el producto correctamente: visible=true, rejection_reason=null", async () => { prisma.products.findFirst.mockResolvedValue(mockProduct); - prisma.$transaction.mockResolvedValue([mockProductApproved]); + prisma.$transaction.mockResolvedValue([{ count: 1 }]); + prisma.products.findUnique.mockResolvedValue(mockProductApproved); - const result = await updateProductApprovalStatusService(1, 1, { status: "ACTIVE" }); + const result = await updateProductApprovalStatusService(1, { status: "ACTIVE" }); expect(prisma.$transaction).toHaveBeenCalled(); expect(result.approvalStatus).toBe("ACTIVE"); @@ -334,21 +339,23 @@ describe("updateProductApprovalStatusService", () => { it("al aprobar NO crea notificación", async () => { prisma.products.findFirst.mockResolvedValue(mockProduct); prisma.$transaction.mockImplementation(async (ops) => { - // Solo debe haber 1 operación (update), sin notificación + // Solo debe haber 1 operación (updateMany), sin notificación expect(ops).toHaveLength(1); - return [mockProductApproved]; + return [{ count: 1 }]; }); + prisma.products.findUnique.mockResolvedValue(mockProductApproved); - await updateProductApprovalStatusService(1, 1, { status: "ACTIVE" }); + 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([mockProductRejected]); + prisma.$transaction.mockResolvedValue([{ count: 1 }, {}]); + prisma.products.findUnique.mockResolvedValue(mockProductRejected); - const result = await updateProductApprovalStatusService(1, 1, { + const result = await updateProductApprovalStatusService(1, { status: "REJECTED", reason: "Producto falsificado confirmado", }); @@ -361,12 +368,13 @@ describe("updateProductApprovalStatusService", () => { it("al rechazar crea notificación para el vendedor", async () => { prisma.products.findFirst.mockResolvedValue(mockProduct); prisma.$transaction.mockImplementation(async (ops) => { - // Debe haber 2 operaciones: update + notification + // Debe haber 2 operaciones: updateMany + notification expect(ops).toHaveLength(2); - return [mockProductRejected]; + return [{ count: 1 }, {}]; }); + prisma.products.findUnique.mockResolvedValue(mockProductRejected); - await updateProductApprovalStatusService(1, 1, { + await updateProductApprovalStatusService(1, { status: "REJECTED", reason: "Producto falsificado", }); @@ -374,10 +382,11 @@ describe("updateProductApprovalStatusService", () => { it("normaliza status a mayúsculas antes de procesar", async () => { prisma.products.findFirst.mockResolvedValue(mockProduct); - prisma.$transaction.mockResolvedValue([mockProductApproved]); + prisma.$transaction.mockResolvedValue([{ count: 1 }]); + prisma.products.findUnique.mockResolvedValue(mockProductApproved); await expect( - updateProductApprovalStatusService(1, 1, { status: "active" }) + updateProductApprovalStatusService(1, { status: "active" }) ).resolves.not.toThrow(); }); }); \ No newline at end of file