diff --git a/src/modules/users/cart/cart.controller.js b/src/modules/users/cart/cart.controller.js index bfc8c25..2026209 100644 --- a/src/modules/users/cart/cart.controller.js +++ b/src/modules/users/cart/cart.controller.js @@ -1,4 +1,9 @@ -import { addCartItemService, getActiveCartsForUserService, getCartItemsByIdService } from "./cart.service.js"; +import { + addCartItemService, + getActiveCartsForUserService, + getCartItemsByIdService, + removeCartItemService, + updatedCartItemQuantityService } from "./cart.service.js"; /** * GET /api/users/:customerId/carts @@ -67,4 +72,44 @@ export const getCartItemsById = async (req, res, next) => { } catch (error) { next(error); } -} \ No newline at end of file +} + +/** + * + * DELETE /api/users/cart/items/:cartItemId - elimina un item del carrito de compras + */ +export const removeCartItem = async (req, res, next) => { + try { + if (!req.user?.id_user) { + return res.status(401).json({ + message: "Usuario autenticado requerido" + }); + } + const { cartItemId } = req.params; + const updatedCart = await removeCartItemService(req.user.id_user, cartItemId); + return res.status(200).json(updatedCart); + } catch (error) { + next(error); + } +}; + +/** + * + * PUT /api/users/cart/items/:cartItemId - actualiza la cantidad de un item del carrito de compras + * Body: { quantity } + */ +export const updateCartItemQuantity = async (req, res, next) => { + try { + if (!req.user?.id_user) { + return res.status(401).json({ + message: "Usuario autenticado requerido" + }); + } + const { cartItemId } = req.params; + const { quantity } = req.body; + const updatedCart = await updatedCartItemQuantityService(req.user.id_user, cartItemId, quantity); + return res.status(200).json(updatedCart); + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/src/modules/users/cart/cart.routes.js b/src/modules/users/cart/cart.routes.js index 700b38f..45456e6 100644 --- a/src/modules/users/cart/cart.routes.js +++ b/src/modules/users/cart/cart.routes.js @@ -1,6 +1,6 @@ import { Router } from "express"; import authenticate from "../../../config/jwt.config.js"; -import { getCarts, addCartItem, getCartItemsById } from "./cart.controller.js"; +import { getCarts, addCartItem, getCartItemsById, removeCartItem, updateCartItemQuantity } from "./cart.controller.js"; const router = Router({ mergeParams: true }); @@ -13,4 +13,10 @@ router.post("/:customerId/cart/items", authenticate, addCartItem); // GET /api/users/cart/:cartId/items - obtiene los items de un carrito especifico router.get("/cart/:cartId/items", authenticate, getCartItemsById); +// DELETE /api/users/cart/items/:cartItemId - elimina un item del carrito de compras +router.delete("/cart/items/:cartItemId", authenticate, removeCartItem); + + +// PUT /api/users/cart/items/:cartItemId - actualiza la cantidad de un item del carrito de compras +router.put("/cart/items/:cartItemId", authenticate, updateCartItemQuantity); export default router; diff --git a/src/modules/users/cart/cart.service.js b/src/modules/users/cart/cart.service.js index e43602f..d2bc79f 100644 --- a/src/modules/users/cart/cart.service.js +++ b/src/modules/users/cart/cart.service.js @@ -292,4 +292,85 @@ export const getCartItemsByIdService = async (authenticatedUserId, cartId) => { } }; }); -}; \ No newline at end of file +}; + +/** + * Elimina un item del carrito de compras + * DELETE /api/users/cart/items/:cartItemId + * + */ +export const removeCartItemService = async (authenticatedUserId, cartItemId) => { + const resolvedCartItemId = parsePositiveInteger(cartItemId, "cartItemId"); + const resolvedUserId = parsePositiveInteger(authenticatedUserId, "authenticatedUserId"); + + //validar que el item existe, que el carrito al que pertenece es del user autenticado, y que ambos estén activos + const cartItem = await prisma.cartItems.findFirst({ + where: { + id_cart_item: resolvedCartItemId, + status: true, + cart: { + fk_user: resolvedUserId, + status: true, + cart_status: "ACTIVE" + } + }, + select: {id_cart_item: true, fk_cart: true } + }); + + if (!cartItem) throw new NotFoundError("Item de carrito no encontrado."); + + //se aplica el borrado logico del item + const updated = await prisma.cartItems.update({ + where: { id_cart_item: resolvedCartItemId }, + data: { status: false } + }); + + const updatedCart = await getCartWithItems(cartItem.fk_cart); + return mapCartResponse(updatedCart); +}; + +/** + * + * Actualiza la cantidad de un item del carrito de compras + * PUT /api/users/cart/items/:cartItemId + */ +export const updatedCartItemQuantityService = async (authenticatedUserId, cartItemId, newQuantity) => { + const resolvedUserId = parsePositiveInteger(authenticatedUserId, "authenticatedUserId"); + const resolvedCartItemId = parsePositiveInteger(cartItemId, "cartItemId"); + const resolvedQuantity = Number(newQuantity); + + if (!Number.isInteger(resolvedQuantity) || resolvedQuantity < 1) throw new ValidationError("La cantidad nueva debe ser un entero mayor a 0"); + + const cartItem = await prisma.cartItems.findFirst({ + where: { + id_cart_item: resolvedCartItemId, + status: true, + cart: { + fk_user: resolvedUserId, + cart_status: "ACTIVE", + status: true + } + }, select: { + id_cart_item: true, + fk_cart: true, + product: { + select: { quantity: true } + } + } + }); + + if (!cartItem) throw new NotFoundError("Item de carrito no encontrado."); + + //validar que el producto tenga stock suficiente para la cantidad nueva + const stock = cartItem.product.quantity; + if(stock != null && Number.isFinite(Number(stock)) && resolvedQuantity > Number(stock)) + throw new ValidationError("No hay stock suficiente para esta cantidad"); + + await prisma.cartItems.update({ + where: { id_cart_item: resolvedCartItemId }, + data: { quantity: resolvedQuantity } + }); + + const updatedCart = await getCartWithItems(cartItem.fk_cart); + return mapCartResponse(updatedCart); +} \ No newline at end of file diff --git a/tests/unit/cart/cart.test.js b/tests/unit/cart/cart.test.js new file mode 100644 index 0000000..1393e7d --- /dev/null +++ b/tests/unit/cart/cart.test.js @@ -0,0 +1,637 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { prisma } from "../../../src/lib/prisma.js"; +import { + getActiveCartsForUserService, + addCartItemService, + getCartItemsByIdService, + removeCartItemService, + updatedCartItemQuantityService, +} from "../../../src/modules/users/cart/cart.service.js"; +import { + ForbiddenError, + NotFoundError, + ValidationError, +} from "../../../src/lib/errors.js"; + +// ─── MOCK DE PRISMA ────────────────────────────────────────────────────────── + +vi.mock("../../../src/lib/prisma.js", () => ({ + prisma: { + carts: { + findMany: vi.fn(), + findUnique: vi.fn(), + findFirst: vi.fn(), + upsert: vi.fn(), + }, + cartItems: { + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + products: { + findFirst: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +vi.mock("../../../src/lib/product-pricing.js", () => ({ + getProductPricing: vi.fn((product) => ({ + price: product.is_offer && product.offer_price != null + ? Number(product.offer_price) + : Number(product.price), + originalPrice: Number(product.price), + offerPrice: product.offer_price ? Number(product.offer_price) : null, + isOffer: product.is_offer ?? false, + })), +})); + +// ─── DATOS DE PRUEBA ───────────────────────────────────────────────────────── + +const mockProduct = { + id_product: 1, + name: "Producto Test", + price: 100, + offer_price: null, + is_offer: false, +}; + +const mockProductOffer = { + id_product: 2, + name: "Producto Oferta", + price: 100, + offer_price: 75, + is_offer: true, +}; + +const mockCartItem = { + id_cart_item: 1, + quantity: 2, + product: mockProduct, +}; + +const mockCartFull = { + id_cart: 1, + fk_store: 10, + cart_status: "ACTIVE", + store: { id_store: 10, name: "Tienda Test" }, + items: [mockCartItem], +}; + +const mockCartEmpty = { + ...mockCartFull, + items: [], +}; + +// ─── getActiveCartsForUserService ──────────────────────────────────────────── + +describe("getActiveCartsForUserService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza ForbiddenError cuando el usuario autenticado no es el mismo que customerId", async () => { + await expect( + getActiveCartsForUserService(1, 2) + ).rejects.toThrow(ForbiddenError); + }); + + it("retorna array vacío cuando el usuario no tiene carritos activos", async () => { + prisma.carts.findMany.mockResolvedValue([]); + + const result = await getActiveCartsForUserService(1, 1); + + expect(result).toEqual([]); + }); + + it("retorna array vacío cuando los carritos existen pero no tienen items", async () => { + prisma.carts.findMany.mockResolvedValue([{ id_cart: 1 }]); + prisma.carts.findUnique.mockResolvedValue(mockCartEmpty); + + const result = await getActiveCartsForUserService(1, 1); + + expect(result).toEqual([]); + }); + + it("retorna los carritos activos con items correctamente mapeados", async () => { + prisma.carts.findMany.mockResolvedValue([{ id_cart: 1 }]); + prisma.carts.findUnique.mockResolvedValue(mockCartFull); + + const result = await getActiveCartsForUserService(1, 1); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: 1, + storeId: 10, + commerce: { id: 10, name: "Tienda Test" }, + status: "ACTIVE", + }); + expect(result[0].items).toHaveLength(1); + }); + + it("mapea correctamente los campos del producto en cada item", async () => { + prisma.carts.findMany.mockResolvedValue([{ id_cart: 1 }]); + prisma.carts.findUnique.mockResolvedValue(mockCartFull); + + const result = await getActiveCartsForUserService(1, 1); + const item = result[0].items[0]; + + expect(item).toMatchObject({ + id: 1, + quantity: 2, + product: { + id: 1, + name: "Producto Test", + price: 100, + isOffer: false, + }, + }); + }); + + it("retorna múltiples carritos (uno por comercio)", async () => { + prisma.carts.findMany.mockResolvedValue([{ id_cart: 1 }, { id_cart: 2 }]); + prisma.carts.findUnique + .mockResolvedValueOnce(mockCartFull) + .mockResolvedValueOnce({ ...mockCartFull, id_cart: 2, fk_store: 20 }); + + const result = await getActiveCartsForUserService(1, 1); + + expect(result).toHaveLength(2); + }); + + it("lanza ValidationError cuando customerId no es un entero positivo", async () => { + await expect( + getActiveCartsForUserService(1, -1) + ).rejects.toThrow(ValidationError); + }); +}); + +// ─── addCartItemService ────────────────────────────────────────────────────── + +describe("addCartItemService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza ForbiddenError cuando el usuario autenticado no es el customerId", async () => { + await expect( + addCartItemService(1, 2, { productId: 1, quantity: 1 }) + ).rejects.toThrow(ForbiddenError); + }); + + it("lanza ValidationError cuando quantity es 0", async () => { + await expect( + addCartItemService(1, 1, { productId: 1, quantity: 0 }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando quantity es negativa", async () => { + await expect( + addCartItemService(1, 1, { productId: 1, quantity: -3 }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando quantity es decimal", async () => { + await expect( + addCartItemService(1, 1, { productId: 1, quantity: 1.5 }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza NotFoundError cuando el producto no existe o no está disponible", async () => { + prisma.$transaction.mockImplementation(async (fn) => { + const tx = { + products: { findFirst: vi.fn().mockResolvedValue(null) }, + carts: { upsert: vi.fn() }, + cartItems: { findFirst: vi.fn() }, + }; + return fn(tx); + }); + + await expect( + addCartItemService(1, 1, { productId: 99, quantity: 1 }) + ).rejects.toThrow(NotFoundError); + }); + + it("lanza ValidationError cuando el comercio del producto no está activo", async () => { + prisma.$transaction.mockImplementation(async (fn) => { + const tx = { + products: { + findFirst: vi.fn().mockResolvedValue({ + id_product: 1, + fk_store: 10, + quantity: 100, + store: { id_store: 10, store_status: "INACTIVE", status: true }, + }), + }, + carts: { upsert: vi.fn() }, + cartItems: { findFirst: vi.fn() }, + }; + return fn(tx); + }); + + await expect( + addCartItemService(1, 1, { productId: 1, quantity: 1 }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando el comercio tiene status false", async () => { + prisma.$transaction.mockImplementation(async (fn) => { + const tx = { + products: { + findFirst: vi.fn().mockResolvedValue({ + id_product: 1, + fk_store: 10, + quantity: 100, + store: { id_store: 10, store_status: "ACTIVE", status: false }, + }), + }, + carts: { upsert: vi.fn() }, + cartItems: { findFirst: vi.fn() }, + }; + return fn(tx); + }); + + await expect( + addCartItemService(1, 1, { productId: 1, quantity: 1 }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando no hay stock suficiente", async () => { + prisma.$transaction.mockImplementation(async (fn) => { + const tx = { + products: { + findFirst: vi.fn().mockResolvedValue({ + id_product: 1, + fk_store: 10, + quantity: 2, // solo 2 en stock + store: { id_store: 10, store_status: "ACTIVE", status: true }, + }), + }, + carts: { + upsert: vi.fn().mockResolvedValue({ id_cart: 1 }), + }, + cartItems: { + findFirst: vi.fn().mockResolvedValue(null), + }, + }; + return fn(tx); + }); + + await expect( + addCartItemService(1, 1, { productId: 1, quantity: 5 }) // pide 5, hay 2 + ).rejects.toThrow(ValidationError); + }); + + it("crea un nuevo item cuando no existía previamente", async () => { + prisma.$transaction.mockImplementation(async (fn) => { + const tx = { + products: { + findFirst: vi.fn().mockResolvedValue({ + id_product: 1, + fk_store: 10, + quantity: 100, + store: { id_store: 10, store_status: "ACTIVE", status: true }, + }), + }, + carts: { + upsert: vi.fn().mockResolvedValue({ id_cart: 1 }), + }, + cartItems: { + findFirst: vi.fn().mockResolvedValue(null), // no existía + create: vi.fn().mockResolvedValue({}), + }, + }; + return fn(tx); + }); + prisma.carts.findUnique.mockResolvedValue(mockCartFull); + + const result = await addCartItemService(1, 1, { productId: 1, quantity: 2 }); + + expect(result).toMatchObject({ id: 1, storeId: 10 }); + }); + + it("incrementa la cantidad cuando el item ya existe y está activo", async () => { + const existingItem = { id_cart_item: 1, quantity: 2, status: true }; + + prisma.$transaction.mockImplementation(async (fn) => { + const tx = { + products: { + findFirst: vi.fn().mockResolvedValue({ + id_product: 1, + fk_store: 10, + quantity: 100, + store: { id_store: 10, store_status: "ACTIVE", status: true }, + }), + }, + carts: { + upsert: vi.fn().mockResolvedValue({ id_cart: 1 }), + }, + cartItems: { + findFirst: vi.fn().mockResolvedValue(existingItem), + update: vi.fn().mockResolvedValue({}), + }, + }; + return fn(tx); + }); + prisma.carts.findUnique.mockResolvedValue(mockCartFull); + + await addCartItemService(1, 1, { productId: 1, quantity: 3 }); + + // la transacción se ejecutó (update fue llamado dentro) + expect(prisma.$transaction).toHaveBeenCalled(); + }); + + it("reactiva y actualiza cantidad cuando el item existía pero estaba inactivo", async () => { + const inactiveItem = { id_cart_item: 1, quantity: 1, status: false }; + + prisma.$transaction.mockImplementation(async (fn) => { + const tx = { + products: { + findFirst: vi.fn().mockResolvedValue({ + id_product: 1, + fk_store: 10, + quantity: 100, + store: { id_store: 10, store_status: "ACTIVE", status: true }, + }), + }, + carts: { + upsert: vi.fn().mockResolvedValue({ id_cart: 1 }), + }, + cartItems: { + findFirst: vi.fn().mockResolvedValue(inactiveItem), + update: vi.fn().mockResolvedValue({}), + }, + }; + return fn(tx); + }); + prisma.carts.findUnique.mockResolvedValue(mockCartFull); + + await addCartItemService(1, 1, { productId: 1, quantity: 2 }); + + expect(prisma.$transaction).toHaveBeenCalled(); + }); + + it("usa quantity=1 por defecto cuando no se envía", async () => { + prisma.$transaction.mockImplementation(async (fn) => { + const tx = { + products: { + findFirst: vi.fn().mockResolvedValue({ + id_product: 1, + fk_store: 10, + quantity: 100, + store: { id_store: 10, store_status: "ACTIVE", status: true }, + }), + }, + carts: { + upsert: vi.fn().mockResolvedValue({ id_cart: 1 }), + }, + cartItems: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({}), + }, + }; + return fn(tx); + }); + prisma.carts.findUnique.mockResolvedValue(mockCartFull); + + // sin quantity + const result = await addCartItemService(1, 1, { productId: 1 }); + + expect(result).toBeDefined(); + }); +}); + +// ─── getCartItemsByIdService ────────────────────────────────────────────────── + +describe("getCartItemsByIdService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza NotFoundError cuando el carrito no existe o no pertenece al usuario", async () => { + prisma.carts.findFirst.mockResolvedValue(null); + + await expect( + getCartItemsByIdService(1, 99) + ).rejects.toThrow(NotFoundError); + }); + + it("retorna array vacío cuando el carrito no tiene items activos", async () => { + prisma.carts.findFirst.mockResolvedValue({ id_cart: 1 }); + prisma.cartItems.findMany.mockResolvedValue([]); + + const result = await getCartItemsByIdService(1, 1); + + expect(result).toEqual([]); + }); + + it("retorna los items del carrito correctamente mapeados", async () => { + prisma.carts.findFirst.mockResolvedValue({ id_cart: 1 }); + prisma.cartItems.findMany.mockResolvedValue([ + { + id_cart_item: 1, + quantity: 3, + product: { ...mockProduct, store: { name: "Tienda Test" } }, + }, + ]); + + const result = await getCartItemsByIdService(1, 1); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: 1, + quantity: 3, + product: { + id: 1, + name: "Producto Test", + price: 100, + isOffer: false, + storeName: "Tienda Test", + }, + }); + }); + + it("mapea correctamente precio de oferta", async () => { + prisma.carts.findFirst.mockResolvedValue({ id_cart: 1 }); + prisma.cartItems.findMany.mockResolvedValue([ + { + id_cart_item: 2, + quantity: 1, + product: { ...mockProductOffer, store: { name: "Tienda Test" } }, + }, + ]); + + const result = await getCartItemsByIdService(1, 1); + + expect(result[0].product).toMatchObject({ + price: 75, // offer_price + originalPrice: 100, + isOffer: true, + }); + }); + + it("retorna storeName como null cuando el producto no tiene tienda", async () => { + prisma.carts.findFirst.mockResolvedValue({ id_cart: 1 }); + prisma.cartItems.findMany.mockResolvedValue([ + { + id_cart_item: 1, + quantity: 1, + product: { ...mockProduct, store: null }, + }, + ]); + + const result = await getCartItemsByIdService(1, 1); + + expect(result[0].product.storeName).toBeNull(); + }); + + it("lanza ValidationError cuando cartId no es un entero positivo", async () => { + await expect( + getCartItemsByIdService(1, -5) + ).rejects.toThrow(ValidationError); + }); +}); + +// ─── removeCartItemService ──────────────────────────────────────────────────── + +describe("removeCartItemService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza NotFoundError cuando el item no existe o no pertenece al usuario", async () => { + prisma.cartItems.findFirst.mockResolvedValue(null); + + await expect( + removeCartItemService(1, 99) + ).rejects.toThrow(NotFoundError); + }); + + it("aplica borrado lógico (status: false) al item", async () => { + prisma.cartItems.findFirst.mockResolvedValue({ + id_cart_item: 1, + fk_cart: 1, + }); + prisma.cartItems.update.mockResolvedValue({}); + prisma.carts.findUnique.mockResolvedValue(mockCartEmpty); + + await removeCartItemService(1, 1); + + expect(prisma.cartItems.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id_cart_item: 1 }, + data: { status: false }, + }) + ); + }); + + it("retorna el carrito actualizado después de eliminar el item", async () => { + prisma.cartItems.findFirst.mockResolvedValue({ + id_cart_item: 1, + fk_cart: 1, + }); + prisma.cartItems.update.mockResolvedValue({}); + prisma.carts.findUnique.mockResolvedValue(mockCartFull); + + const result = await removeCartItemService(1, 1); + + expect(result).toMatchObject({ id: 1, storeId: 10 }); + }); + + it("lanza ValidationError cuando cartItemId no es un entero positivo", async () => { + await expect( + removeCartItemService(1, -1) + ).rejects.toThrow(ValidationError); + }); +}); + +// ─── updatedCartItemQuantityService ────────────────────────────────────────── + +describe("updatedCartItemQuantityService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza ValidationError cuando la nueva cantidad es 0", async () => { + await expect( + updatedCartItemQuantityService(1, 1, 0) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando la nueva cantidad es negativa", async () => { + await expect( + updatedCartItemQuantityService(1, 1, -2) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando la nueva cantidad es decimal", async () => { + await expect( + updatedCartItemQuantityService(1, 1, 2.5) + ).rejects.toThrow(ValidationError); + }); + + it("lanza NotFoundError cuando el item no existe o no pertenece al usuario", async () => { + prisma.cartItems.findFirst.mockResolvedValue(null); + + await expect( + updatedCartItemQuantityService(1, 99, 3) + ).rejects.toThrow(NotFoundError); + }); + + it("lanza ValidationError cuando la nueva cantidad supera el stock disponible", async () => { + prisma.cartItems.findFirst.mockResolvedValue({ + id_cart_item: 1, + fk_cart: 1, + product: { quantity: 5 }, // solo 5 en stock + }); + + await expect( + updatedCartItemQuantityService(1, 1, 10) // pide 10 + ).rejects.toThrow(ValidationError); + }); + + it("actualiza la cantidad correctamente cuando hay stock suficiente", async () => { + prisma.cartItems.findFirst.mockResolvedValue({ + id_cart_item: 1, + fk_cart: 1, + product: { quantity: 100 }, + }); + prisma.cartItems.update.mockResolvedValue({}); + prisma.carts.findUnique.mockResolvedValue(mockCartFull); + + await updatedCartItemQuantityService(1, 1, 5); + + expect(prisma.cartItems.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id_cart_item: 1 }, + data: { quantity: 5 }, + }) + ); + }); + + it("actualiza correctamente cuando el producto no tiene límite de stock (quantity null)", async () => { + prisma.cartItems.findFirst.mockResolvedValue({ + id_cart_item: 1, + fk_cart: 1, + product: { quantity: null }, // sin límite de stock + }); + prisma.cartItems.update.mockResolvedValue({}); + prisma.carts.findUnique.mockResolvedValue(mockCartFull); + + await updatedCartItemQuantityService(1, 1, 999); + + expect(prisma.cartItems.update).toHaveBeenCalled(); + }); + + it("retorna el carrito actualizado con los nuevos valores", async () => { + prisma.cartItems.findFirst.mockResolvedValue({ + id_cart_item: 1, + fk_cart: 1, + product: { quantity: 100 }, + }); + prisma.cartItems.update.mockResolvedValue({}); + prisma.carts.findUnique.mockResolvedValue(mockCartFull); + + const result = await updatedCartItemQuantityService(1, 1, 3); + + expect(result).toMatchObject({ id: 1, storeId: 10 }); + expect(Array.isArray(result.items)).toBe(true); + }); + + it("lanza ValidationError cuando cartItemId no es un entero positivo", async () => { + await expect( + updatedCartItemQuantityService(1, -1, 3) + ).rejects.toThrow(ValidationError); + }); +}); \ No newline at end of file diff --git a/tests/unit/order/order.test.js b/tests/unit/order/order.test.js new file mode 100644 index 0000000..c0a2532 --- /dev/null +++ b/tests/unit/order/order.test.js @@ -0,0 +1,610 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { prisma } from "../../../src/lib/prisma.js"; +import { + createOrderService, + getOrdersService, + getStoreOrdersService, + updateOrderStatusService, +} from "../../../src/modules/users/orders/order.service.js"; +import { + ValidationError, + ForbiddenError, + NotFoundError, + ConflictError, +} from "../../../src/lib/errors.js"; + + +// ─── MOCK DE PRISMA ────────────────────────────────────────────────────────── + +vi.mock("../../../src/lib/prisma.js", () => ({ + prisma: { + carts: { + findFirst: vi.fn(), + update: vi.fn(), + }, + addresses: { + findFirst: vi.fn(), + }, + orders: { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + count: vi.fn(), + update: vi.fn(), + }, + orderItems: { + createMany: vi.fn(), + }, + stores: { + findFirst: vi.fn(), + }, + users: { + findFirst: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +// ─── DATOS DE PRUEBA ───────────────────────────────────────────────────────── + +const mockProductNormal = { + id_product: 1, + price: 100, + offer_price: null, + is_offer: false, + status: true, + visible: true, +}; + +const mockProductOffer = { + id_product: 2, + price: 100, + offer_price: 80, + is_offer: true, + status: true, + visible: true, +}; + +const mockProductUnavailable = { + id_product: 3, + price: 50, + offer_price: null, + is_offer: false, + status: false, // producto inactivo + visible: true, +}; + +const mockCart = { + id_cart: 1, + fk_store: 10, + order: null, + items: [ + { fk_product: 1, quantity: 2, product: mockProductNormal }, + ], +}; + +const mockAddress = { id_address: 1, fk_user: 1, status: true }; + +const mockOrderFromDB = { + id_order: 100, + order_status: "PENDING", + total: 200, + notes: null, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + address: null, + order_items: [ + { + id_order_item: 1, + quantity: 2, + price: 100, + original_price: 100, + is_offer_applied: false, + subtotal: 200, + }, + ], +}; + +// ─── createOrderService ─────────────────────────────────────────────────────── + +describe("createOrderService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza NotFoundError cuando el carrito no existe", async () => { + prisma.carts.findFirst.mockResolvedValue(null); + + await expect( + createOrderService(1, { cartId: 99, addressId: null, notes: null, total: 200 }) + ).rejects.toThrow(NotFoundError); + }); + + it("lanza ConflictError cuando el carrito ya fue convertido en pedido", async () => { + prisma.carts.findFirst.mockResolvedValue({ + ...mockCart, + order: { id_order: 50 }, // ya tiene pedido + }); + + await expect( + createOrderService(1, { cartId: 1, addressId: null, notes: null, total: 200 }) + ).rejects.toThrow(ConflictError); + }); + + it("lanza ValidationError cuando el carrito no tiene items", async () => { + prisma.carts.findFirst.mockResolvedValue({ ...mockCart, items: [] }); + + await expect( + createOrderService(1, { cartId: 1, addressId: null, notes: null, total: 200 }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando hay productos no disponibles", async () => { + prisma.carts.findFirst.mockResolvedValue({ + ...mockCart, + items: [{ fk_product: 3, quantity: 1, product: mockProductUnavailable }], + }); + + await expect( + createOrderService(1, { cartId: 1, addressId: null, notes: null, total: 50 }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando hay productos no visibles", async () => { + prisma.carts.findFirst.mockResolvedValue({ + ...mockCart, + items: [ + { + fk_product: 4, + quantity: 1, + product: { ...mockProductNormal, visible: false }, + }, + ], + }); + + await expect( + createOrderService(1, { cartId: 1, addressId: null, notes: null, total: 100 }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza NotFoundError cuando la dirección no existe o no pertenece al usuario", async () => { + prisma.carts.findFirst.mockResolvedValue(mockCart); + prisma.addresses.findFirst.mockResolvedValue(null); + + await expect( + createOrderService(1, { cartId: 1, addressId: 99, notes: null, total: 200 }) + ).rejects.toThrow(NotFoundError); + }); + + it("crea la orden correctamente con dirección válida", async () => { + prisma.carts.findFirst.mockResolvedValue(mockCart); + prisma.addresses.findFirst.mockResolvedValue(mockAddress); + prisma.$transaction.mockImplementation(async (fn) => fn(prisma)); + prisma.orders.create.mockResolvedValue({ id_order: 100 }); + prisma.orderItems.createMany.mockResolvedValue({}); + prisma.carts.update.mockResolvedValue({}); + prisma.orders.findUnique.mockResolvedValue(mockOrderFromDB); + + const result = await createOrderService(1, { + cartId: 1, + addressId: 1, + notes: "entregar en la mañana", + total: 200, + }); + + expect(result).toMatchObject({ + id: 100, + status: "PENDING", + total: 200, + }); + }); + + it("crea la orden sin dirección (retiro en tienda)", async () => { + prisma.carts.findFirst.mockResolvedValue(mockCart); + prisma.$transaction.mockImplementation(async (fn) => fn(prisma)); + prisma.orders.create.mockResolvedValue({ id_order: 100 }); + prisma.orderItems.createMany.mockResolvedValue({}); + prisma.carts.update.mockResolvedValue({}); + prisma.orders.findUnique.mockResolvedValue(mockOrderFromDB); + + const result = await createOrderService(1, { + cartId: 1, + addressId: null, + notes: null, + total: 200, + }); + + expect(result.id).toBe(100); + // No debe haber consultado direcciones + expect(prisma.addresses.findFirst).not.toHaveBeenCalled(); + }); + + // ─── Cálculo de precios históricos ─────────────────────────────────────── + + it("calcula precio sin oferta correctamente (price normal)", async () => { + prisma.carts.findFirst.mockResolvedValue({ + ...mockCart, + items: [{ fk_product: 1, quantity: 3, product: mockProductNormal }], + }); + prisma.$transaction.mockImplementation(async (fn) => fn(prisma)); + prisma.orders.create.mockResolvedValue({ id_order: 100 }); + prisma.orderItems.createMany.mockResolvedValue({}); + prisma.carts.update.mockResolvedValue({}); + prisma.orders.findUnique.mockResolvedValue(mockOrderFromDB); + + await createOrderService(1, { cartId: 1, addressId: null, notes: null, total: 300 }); + + const itemsCreated = prisma.orderItems.createMany.mock.calls[0][0].data; + expect(itemsCreated[0]).toMatchObject({ + fk_product: 1, + quantity: 3, + price: 100, // price normal + original_price: 100, + is_offer_applied: false, + subtotal: 300, // 100 * 3 + }); + }); + + it("calcula precio con oferta correctamente (usa offer_price)", async () => { + prisma.carts.findFirst.mockResolvedValue({ + ...mockCart, + items: [{ fk_product: 2, quantity: 2, product: mockProductOffer }], + }); + prisma.$transaction.mockImplementation(async (fn) => fn(prisma)); + prisma.orders.create.mockResolvedValue({ id_order: 100 }); + prisma.orderItems.createMany.mockResolvedValue({}); + prisma.carts.update.mockResolvedValue({}); + prisma.orders.findUnique.mockResolvedValue(mockOrderFromDB); + + await createOrderService(1, { cartId: 1, addressId: null, notes: null, total: 160 }); + + const itemsCreated = prisma.orderItems.createMany.mock.calls[0][0].data; + expect(itemsCreated[0]).toMatchObject({ + fk_product: 2, + quantity: 2, + price: 80, // offer_price + original_price: 100, // price original + is_offer_applied: true, + subtotal: 160, // 80 * 2 + }); + }); + + it("lanza ValidationError cuando cartId no es un entero positivo", async () => { + await expect( + createOrderService(1, { cartId: -1, addressId: null, notes: null, total: 100 }) + ).rejects.toThrow(ValidationError); + }); +}); + +// ─── getOrdersService ───────────────────────────────────────────────────────── + +describe("getOrdersService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna los pedidos del propio usuario sin consultar tiendas", async () => { + prisma.orders.findMany.mockResolvedValue([mockOrderFromDB]); + + const result = await getOrdersService(1, 1); // mismo usuario + + expect(prisma.stores.findFirst).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(100); + }); + + it("lanza ForbiddenError cuando un SELLER intenta ver pedidos de otro cliente sin tienda en común", async () => { + prisma.stores.findFirst.mockResolvedValue(null); + + await expect(getOrdersService(2, 1)).rejects.toThrow(ForbiddenError); + }); + + it("retorna pedidos filtrados por tienda cuando el SELLER tiene permisos", async () => { + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + prisma.orders.findMany.mockResolvedValue([mockOrderFromDB]); + + const result = await getOrdersService(2, 1); // seller 2 ve pedidos del cliente 1 + + expect(prisma.orders.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ fk_store: 10 }), + }) + ); + expect(result).toHaveLength(1); + }); + + it("retorna array vacío cuando el usuario no tiene pedidos", async () => { + prisma.orders.findMany.mockResolvedValue([]); + + const result = await getOrdersService(1, 1); + + expect(result).toEqual([]); + }); + + it("mapea correctamente los campos del pedido", async () => { + prisma.orders.findMany.mockResolvedValue([mockOrderFromDB]); + + const result = await getOrdersService(1, 1); + const order = result[0]; + + expect(order).toMatchObject({ + id: 100, + status: "PENDING", + total: 200, + notes: null, + address: null, + }); + expect(Array.isArray(order.items)).toBe(true); + }); +}); + +// ─── getStoreOrdersService ──────────────────────────────────────────────────── + +describe("getStoreOrdersService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza NotFoundError cuando la tienda no existe o no pertenece al usuario", async () => { + prisma.stores.findFirst.mockResolvedValue(null); + + await expect(getStoreOrdersService(1, 99, {})).rejects.toThrow(NotFoundError); + }); + + it("retorna pedidos con estructura de paginación correcta", async () => { + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + prisma.$transaction.mockResolvedValue([[mockOrderFromDB], 1]); + + const result = await getStoreOrdersService(1, 10, { page: 1, limit: 10 }); + + expect(result).toMatchObject({ + total: 1, + page: 1, + limit: 10, + total_page: 1, + }); + expect(result.orders).toHaveLength(1); + }); + + it("aplica filtro por order_status correctamente", async () => { + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + prisma.$transaction.mockResolvedValue([[mockOrderFromDB], 1]); + + await getStoreOrdersService(1, 10, { order_status: "PENDING" }); + + const [findManyCall] = prisma.$transaction.mock.calls[0][0]; + // verificamos que la transacción fue llamada + expect(prisma.$transaction).toHaveBeenCalled(); + }); + + it("aplica filtro por múltiples estados separados por coma", async () => { + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + prisma.$transaction.mockResolvedValue([[], 0]); + + await getStoreOrdersService(1, 10, { order_status: "PENDING,PROCESSING" }); + + expect(prisma.$transaction).toHaveBeenCalled(); + }); + + it("aplica filtro por rango de fechas", async () => { + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + prisma.$transaction.mockResolvedValue([[], 0]); + + await getStoreOrdersService(1, 10, { + date_from: "2026-01-01", + date_to: "2026-12-31", + }); + + expect(prisma.$transaction).toHaveBeenCalled(); + }); + + it("calcula total_page correctamente", async () => { + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + prisma.$transaction.mockResolvedValue([[mockOrderFromDB], 25]); + + const result = await getStoreOrdersService(1, 10, { page: 1, limit: 10 }); + + expect(result.total_page).toBe(3); // ceil(25/10) + }); + + it("usa valores por defecto page=1 y limit=10 cuando no se envían", async () => { + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + prisma.$transaction.mockResolvedValue([[], 0]); + + const result = await getStoreOrdersService(1, 10, {}); + + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + }); +}); + +// ─── updateOrderStatusService ──────────────────────────────────────────────── + +describe("updateOrderStatusService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza NotFoundError cuando el usuario no existe", async () => { + prisma.users.findFirst.mockResolvedValue(null); + + await expect( + updateOrderStatusService(1, 100, "PROCESSING") + ).rejects.toThrow(NotFoundError); + }); + + it("lanza NotFoundError cuando el pedido no existe", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "SELLER" }); + prisma.orders.findFirst.mockResolvedValue(null); + + await expect( + updateOrderStatusService(1, 999, "PROCESSING") + ).rejects.toThrow(NotFoundError); + }); + + // ─── SELLER ────────────────────────────────────────────────────────────── + + it("lanza ForbiddenError cuando el SELLER intenta modificar un pedido de otra tienda", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "SELLER" }); + prisma.orders.findFirst.mockResolvedValue({ + id_order: 100, + order_status: "PENDING", + fk_store: 10, + }); + prisma.stores.findFirst.mockResolvedValue(null); // no es su tienda + + await expect( + updateOrderStatusService(2, 100, "PROCESSING") + ).rejects.toThrow(ForbiddenError); + }); + + it("SELLER puede pasar de PENDING a PROCESSING", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "SELLER" }); + prisma.orders.findFirst.mockResolvedValue({ + id_order: 100, + order_status: "PENDING", + fk_store: 10, + }); + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + prisma.orders.update.mockResolvedValue(mockOrderFromDB); + + await updateOrderStatusService(1, 100, "PROCESSING"); + + expect(prisma.orders.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { order_status: "PROCESSING" } }) + ); + }); + + it("SELLER puede pasar de PENDING a CANCELLED", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "SELLER" }); + prisma.orders.findFirst.mockResolvedValue({ + id_order: 100, + order_status: "PENDING", + fk_store: 10, + }); + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + prisma.orders.update.mockResolvedValue(mockOrderFromDB); + + await updateOrderStatusService(1, 100, "CANCELLED"); + + expect(prisma.orders.update).toHaveBeenCalled(); + }); + + it("SELLER puede pasar de PROCESSING a SHIPPED", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "SELLER" }); + prisma.orders.findFirst.mockResolvedValue({ + id_order: 100, + order_status: "PROCESSING", + fk_store: 10, + }); + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + prisma.orders.update.mockResolvedValue(mockOrderFromDB); + + await updateOrderStatusService(1, 100, "SHIPPED"); + + expect(prisma.orders.update).toHaveBeenCalled(); + }); + + it("lanza ValidationError cuando el SELLER intenta una transición inválida (SHIPPED → DELIVERED)", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "SELLER" }); + prisma.orders.findFirst.mockResolvedValue({ + id_order: 100, + order_status: "SHIPPED", + fk_store: 10, + }); + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + + await expect( + updateOrderStatusService(1, 100, "DELIVERED") + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando el SELLER intenta modificar un pedido ya CANCELLED", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "SELLER" }); + prisma.orders.findFirst.mockResolvedValue({ + id_order: 100, + order_status: "CANCELLED", + fk_store: 10, + }); + prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); + + await expect( + updateOrderStatusService(1, 100, "PROCESSING") + ).rejects.toThrow(ValidationError); + }); + + // ─── DELIVERY ───────────────────────────────────────────────────────────── + + it("DELIVERY puede pasar de SHIPPED a DELIVERED", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "DELIVERY" }); + prisma.orders.findFirst.mockResolvedValue({ + id_order: 100, + order_status: "SHIPPED", + fk_store: 10, + }); + prisma.orders.update.mockResolvedValue(mockOrderFromDB); + + await updateOrderStatusService(1, 100, "DELIVERED"); + + expect(prisma.orders.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { order_status: "DELIVERED" } }) + ); + }); + + it("lanza ValidationError cuando DELIVERY intenta una transición inválida (PENDING → DELIVERED)", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "DELIVERY" }); + prisma.orders.findFirst.mockResolvedValue({ + id_order: 100, + order_status: "PENDING", + fk_store: 10, + }); + + await expect( + updateOrderStatusService(1, 100, "DELIVERED") + ).rejects.toThrow(ValidationError); + }); + + // ─── CUSTOMER ───────────────────────────────────────────────────────────── + + it("lanza ForbiddenError cuando el CUSTOMER intenta modificar un pedido ajeno", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "CUSTOMER" }); + prisma.orders.findFirst + .mockResolvedValueOnce({ id_order: 100, order_status: "PENDING", fk_store: 10 }) + .mockResolvedValueOnce(null); // el pedido no le pertenece + + await expect( + updateOrderStatusService(2, 100, "CANCELLED") + ).rejects.toThrow(ForbiddenError); + }); + + it("CUSTOMER puede cancelar su pedido cuando está en PENDING", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "CUSTOMER" }); + prisma.orders.findFirst + .mockResolvedValueOnce({ id_order: 100, order_status: "PENDING", fk_store: 10 }) + .mockResolvedValueOnce({ id_order: 100 }); // es su pedido + prisma.orders.update.mockResolvedValue(mockOrderFromDB); + + await updateOrderStatusService(1, 100, "CANCELLED"); + + expect(prisma.orders.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { order_status: "CANCELLED" } }) + ); + }); + + it("lanza ValidationError cuando el CUSTOMER intenta cancelar un pedido en PROCESSING", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "CUSTOMER" }); + prisma.orders.findFirst + .mockResolvedValueOnce({ id_order: 100, order_status: "PROCESSING", fk_store: 10 }) + .mockResolvedValueOnce({ id_order: 100 }); + + await expect( + updateOrderStatusService(1, 100, "CANCELLED") + ).rejects.toThrow(ValidationError); + }); + + it("lanza ForbiddenError cuando el rol no tiene transiciones definidas (ej: rol desconocido)", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "ADMIN" }); // rol sin transiciones + prisma.orders.findFirst.mockResolvedValue({ + id_order: 100, + order_status: "PENDING", + fk_store: 10, + }); + + await expect( + updateOrderStatusService(1, 100, "PROCESSING") + ).rejects.toThrow(ForbiddenError); + }); +}); \ No newline at end of file