diff --git a/prisma/migrations/20260528123758_allow_multiple_checked_out_carts/migration.sql b/prisma/migrations/20260528123758_allow_multiple_checked_out_carts/migration.sql new file mode 100644 index 00000000..b33e20fc --- /dev/null +++ b/prisma/migrations/20260528123758_allow_multiple_checked_out_carts/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[fk_user,fk_store,cart_status]` on the table `Carts` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "Carts_fk_user_fk_store_cart_status_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "Carts_fk_user_fk_store_cart_status_key" ON "Carts"("fk_user", "fk_store", "cart_status") WHERE ("cart_status" = 'ACTIVE'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b5cd7762..df818ac2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6,7 +6,7 @@ generator client { provider = "prisma-client-js" - previewFeatures = ["fullTextSearchPostgres"] //funcionalidad para busquedas mas avanzadas en la BD (lo use en product.service) + previewFeatures = ["fullTextSearchPostgres", "partialIndexes"] // fullTextSearch para búsquedas avanzadas en BD, partialIndexes para permitir múltiples carritos CHECKED_OUT por tienda //output = "../generated/prisma" } @@ -384,7 +384,7 @@ model Carts { items CartItems[] order Orders? //Relacion 1 a 1 Carrito <-> Pedido - @@unique([fk_user, fk_store, cart_status]) // 1 carrito ACTIVO por tienda para el usuario + @@unique([fk_user, fk_store, cart_status], where: { cart_status: "ACTIVE" }) // solo ACTIVE es único } model CartItems { diff --git a/src/config/swagger.config.js b/src/config/swagger.config.js index c52e3c5e..b912bd81 100644 --- a/src/config/swagger.config.js +++ b/src/config/swagger.config.js @@ -21,6 +21,8 @@ import { // /admin adminCategorySchemas, adminBannerSchemas, + // /users + cartSchemas, } from "../docs/schemas/index.js"; const options = { @@ -68,6 +70,8 @@ const options = { }, schemas: { ...commonSchemas, + // /users/ + ...cartSchemas, // /commerce/ ...addressSchemas, ...storeSchemas, diff --git a/src/docs/schemas/users/cart.schema.js b/src/docs/schemas/users/cart.schema.js index e69de29b..50d8e13d 100644 --- a/src/docs/schemas/users/cart.schema.js +++ b/src/docs/schemas/users/cart.schema.js @@ -0,0 +1,89 @@ +// src/docs/schemas/users/cart.schema.js + +export const cartSchemas = { + CartItem: { + type: "object", + properties: { + id: { type: "integer", example: 1 }, + quantity: { type: "integer", example: 2 }, + product: { + type: "object", + properties: { + id: { type: "integer", example: 10 }, + name: { type: "string", example: "Laptop Dell" }, + price: { type: "number", example: 1500000 }, + originalPrice: { type: "number", example: 1500000 }, + offerPrice: { type: "number", nullable: true, example: 1350000 }, + isOffer: { type: "boolean", example: true }, + imageUrl: { type: "string", nullable: true, example: "https://cdn.example.com/products/10/image.jpg" } + } + } + } + }, + + CartCommerceInfo: { + type: "object", + properties: { + id: { type: "integer", example: 1 }, + name: { type: "string", example: "Electrónica Carlos" }, + logo: { type: "string", nullable: true, example: "https://cdn.example.com/stores/1/logo.jpg" } + } + }, + + Cart: { + type: "object", + properties: { + id: { type: "integer", example: 5 }, + storeId: { type: "integer", example: 1 }, + commerce: { $ref: "#/components/schemas/CartCommerceInfo" }, + status: { type: "string", example: "ACTIVE" }, + items: { + type: "array", + items: { $ref: "#/components/schemas/CartItem" } + } + } + }, + + GetCartsResponse: { + type: "object", + properties: { + carts: { + type: "array", + items: { $ref: "#/components/schemas/Cart" } + } + } + }, + + DeleteCartResponse: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + message: { type: "string", example: "Carrito eliminado correctamente" } + } + }, + + DeleteAllCartsResponse: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + message: { type: "string", example: "Todos los carritos fueron eliminados correctamente" } + } + }, + + CartErrorResponse: { + type: "object", + properties: { + message: { type: "string", example: "Carrito no encontrado" } + } + }, + + CartValidationError: { + type: "object", + properties: { + message: { + type: "string", + example: "No tienes permisos para eliminar este carrito" + } + } + } +}; \ No newline at end of file diff --git a/src/modules/users/cart/cart.service.js b/src/modules/users/cart/cart.service.js index 6e7878cc..0344948d 100644 --- a/src/modules/users/cart/cart.service.js +++ b/src/modules/users/cart/cart.service.js @@ -10,9 +10,10 @@ import { parsePositiveInteger } from "../../../lib/validators.js"; /** * Un usuario tiene a lo sumo un carrito ACTIVE por comercio (@@unique en schema). + * Usa upsert para evitar race conditions en findFirst+create. */ const getOrCreateActiveCart = async (tx, userId, storeId) => { - return tx.carts.upsert({ + const cart = await tx.carts.upsert({ where: { fk_user_fk_store_cart_status: { fk_user: userId, @@ -20,7 +21,7 @@ const getOrCreateActiveCart = async (tx, userId, storeId) => { cart_status: "ACTIVE" } }, - update: {}, // no actualiza nada si ya existe + update: {}, create: { fk_user: userId, fk_store: storeId, @@ -28,6 +29,8 @@ const getOrCreateActiveCart = async (tx, userId, storeId) => { status: true } }); + + return cart; }; const getCartWithItems = async (cartId) => { diff --git a/src/modules/users/orders/order.service.js b/src/modules/users/orders/order.service.js index 7437d85e..cced0ad1 100644 --- a/src/modules/users/orders/order.service.js +++ b/src/modules/users/orders/order.service.js @@ -391,11 +391,21 @@ export const createOrderService = async ( }); } - //marcar el carrito como CHECKED_OUT + // Marcar todos los carritos CHECKED_OUT anteriores como ABANDONED + await tx.carts.updateMany({ + where: { + fk_user: resolvedUserId, + fk_store: cart.fk_store, + cart_status: "CHECKED_OUT" + }, + data: { cart_status: "ABANDONED" } + }); + + // Ahora sí marcar el carrito actual como CHECKED_OUT await tx.carts.update({ where: { id_cart: resolvedCartId }, data: { cart_status: "CHECKED_OUT" } - }) + }); // crear notificación de nuevo pedido para el cliente const { title, message } = NOTIFICATION_MESSAGES.ORDER_CONFIRMED(order.id_order); diff --git a/tests/unit/cart/cart.test.js b/tests/unit/cart/cart.test.js index 2f92794c..2a489afb 100644 --- a/tests/unit/cart/cart.test.js +++ b/tests/unit/cart/cart.test.js @@ -263,7 +263,7 @@ describe("addCartItemService", () => { findFirst: vi.fn().mockResolvedValue({ id_product: 1, fk_store: 10, - quantity: 2, // solo 2 en stock + quantity: 2, store: { id_store: 10, store_status: "ACTIVE", status: true }, }), }, @@ -278,7 +278,7 @@ describe("addCartItemService", () => { }); await expect( - addCartItemService(1, 1, { productId: 1, quantity: 5 }) // pide 5, hay 2 + addCartItemService(1, 1, { productId: 1, quantity: 5 }) ).rejects.toThrow(ValidationError); }); @@ -297,7 +297,7 @@ describe("addCartItemService", () => { upsert: vi.fn().mockResolvedValue({ id_cart: 1 }), }, cartItems: { - findFirst: vi.fn().mockResolvedValue(null), // no existía + findFirst: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue({}), }, }; @@ -337,7 +337,6 @@ describe("addCartItemService", () => { await addCartItemService(1, 1, { productId: 1, quantity: 3 }); - // la transacción se ejecutó (update fue llamado dentro) expect(prisma.$transaction).toHaveBeenCalled(); }); @@ -394,7 +393,6 @@ describe("addCartItemService", () => { }); prisma.carts.findUnique.mockResolvedValue(mockCartFull); - // sin quantity const result = await addCartItemService(1, 1, { productId: 1 }); expect(result).toBeDefined(); @@ -462,7 +460,7 @@ describe("getCartItemsByIdService", () => { const result = await getCartItemsByIdService(1, 1); expect(result[0].product).toMatchObject({ - price: 75, // offer_price + price: 75, originalPrice: 100, isOffer: true, }); @@ -576,11 +574,11 @@ describe("updatedCartItemQuantityService", () => { prisma.cartItems.findFirst.mockResolvedValue({ id_cart_item: 1, fk_cart: 1, - product: { quantity: 5 }, // solo 5 en stock + product: { quantity: 5 }, }); await expect( - updatedCartItemQuantityService(1, 1, 10) // pide 10 + updatedCartItemQuantityService(1, 1, 10) ).rejects.toThrow(ValidationError); }); @@ -728,11 +726,10 @@ describe("deleteCartService", () => { await deleteCartService(1, 1, 1); - // Solo actualiza items con status: true expect(prisma.cartItems.updateMany).toHaveBeenCalledWith({ where: { fk_cart: 1, - status: true, // solo activos + status: true, }, data: { status: false }, }); @@ -802,7 +799,6 @@ describe("deleteAllCartsService", () => { await deleteAllCartsService(1, 1); - // Verifica que findMany fue llamado con los filtros correctos expect(prisma.carts.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { @@ -858,18 +854,16 @@ describe("deleteAllCartsService", () => { await deleteAllCartsService(1, 1); - // Solo actualiza items con status: true expect(prisma.cartItems.updateMany).toHaveBeenCalledWith({ where: { fk_cart: { in: [1, 2] }, - status: true, // solo activos + status: true, }, data: { status: false }, }); }); it("retorna mensaje diferenciado cuando elimina todos vs uno", async () => { - // Comparar deleteCartService vs deleteAllCartsService prisma.carts.findFirst.mockResolvedValue({ id_cart: 1 }); prisma.carts.findMany.mockResolvedValue([{ id_cart: 1 }]); prisma.cartItems.updateMany.mockResolvedValue({ count: 2 }); diff --git a/tests/unit/order/order.test.js b/tests/unit/order/order.test.js index 0d78b1fe..a94e2ad6 100644 --- a/tests/unit/order/order.test.js +++ b/tests/unit/order/order.test.js @@ -13,106 +13,32 @@ import { 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(), - }, - products: { - update: vi.fn(), - }, - orderItems: { - createMany: vi.fn(), - findMany: vi.fn(), // ← agregar findMany - }, - stores: { - findFirst: vi.fn(), - }, - shippingZones: { - findFirst: vi.fn(), - }, - users: { - findFirst: vi.fn(), - }, - deliveries: { - findFirst: vi.fn(), - }, - deliveryAssignments: { - findFirst: vi.fn(), - findMany: vi.fn().mockResolvedValue([]), - create: vi.fn(), - update: vi.fn(), - }, - notifications: { - create: vi.fn() - }, + carts: { findFirst: vi.fn(), update: vi.fn(), updateMany: 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() }, + products: { update: vi.fn() }, + orderItems: { createMany: vi.fn(), findMany: vi.fn() }, + stores: { findFirst: vi.fn() }, + shippingZones: { findFirst: vi.fn() }, + users: { findFirst: vi.fn() }, + deliveries: { findFirst: vi.fn() }, + deliveryAssignments: { findFirst: vi.fn(), findMany: vi.fn().mockResolvedValue([]), create: vi.fn(), update: vi.fn() }, + notifications: { create: 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, - quantity: 10, -}; - -const mockProductOffer = { - id_product: 2, - price: 100, - offer_price: 80, - is_offer: true, - status: true, - visible: true, - quantity: 10, -}; - -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, - latitude: -25.28, - longitude: -57.63, -}; - +const mockProductNormal = { id_product: 1, price: 100, offer_price: null, is_offer: false, status: true, visible: true, quantity: 10 }; +const mockProductOffer = { id_product: 2, price: 100, offer_price: 80, is_offer: true, status: true, visible: true, quantity: 10 }; +const mockProductUnavailable = { id_product: 3, price: 50, offer_price: null, is_offer: false, status: false, 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, latitude: -25.28, longitude: -57.63 }; const mockOrderFromDB = { id_order: 100, order_status: "PENDING", @@ -121,18 +47,22 @@ const mockOrderFromDB = { 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, - }, - ], + order_items: [{ id_order_item: 1, quantity: 2, price: 100, original_price: 100, is_offer_applied: false, subtotal: 200 }], }; +// ─── HELPERS ───────────────────────────────────────────────────────────────── + +const createMockTx = (overrides = {}) => ({ + orders: { create: vi.fn().mockResolvedValue({ id_order: 100 }), findUnique: vi.fn().mockResolvedValue(mockOrderFromDB), update: vi.fn().mockResolvedValue(mockOrderFromDB) }, + orderItems: { createMany: vi.fn().mockResolvedValue({}), findMany: vi.fn().mockResolvedValue([]) }, + carts: { updateMany: vi.fn().mockResolvedValue({}), update: vi.fn().mockResolvedValue({}) }, + products: { update: vi.fn().mockResolvedValue({}) }, + notifications: { create: vi.fn().mockResolvedValue({}) }, + deliveries: { findFirst: vi.fn() }, + deliveryAssignments: { findFirst: vi.fn(), findMany: vi.fn().mockResolvedValue([]), create: vi.fn() }, + ...overrides, +}); + // ─── createOrderService ─────────────────────────────────────────────────────── describe("createOrderService", () => { @@ -140,247 +70,78 @@ describe("createOrderService", () => { 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); + await expect(createOrderService(1, { cartId: 99, addressId: null, notes: null })).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); + prisma.carts.findFirst.mockResolvedValue({ ...mockCart, order: { id_order: 50 } }); + await expect(createOrderService(1, { cartId: 1, addressId: null, notes: null })).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); + await expect(createOrderService(1, { cartId: 1, addressId: null, notes: null })).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); + prisma.carts.findFirst.mockResolvedValue({ ...mockCart, items: [{ fk_product: 3, quantity: 1, product: mockProductUnavailable }] }); + await expect(createOrderService(1, { cartId: 1, addressId: null, notes: null })).rejects.toThrow(ValidationError); }); - it("lanza NotFoundError cuando la dirección no existe o no pertenece al usuario", async () => { - prisma.carts.findFirst.mockResolvedValue(mockCart); - prisma.stores.findFirst.mockResolvedValue({ - id_store: 10, - addresses: [ - { - latitude: -25.28, - longitude: -57.63, - }, - ], - shipping_zones: [ - { - base_price: 10000, - distance_price: 15000, - }, - ], - }); - prisma.addresses.findFirst.mockResolvedValue(null); - - await expect( - createOrderService(1, { - cartId: 1, - addressId: 99, - notes: null, - shippingMethod: "standard", - }) - ).rejects.toThrow(NotFoundError); - }); + it("crea orden correctamente — valida que createMany reciba price sin oferta (100) y subtotal correcto (300)", async () => { + prisma.carts.findFirst.mockResolvedValue({ ...mockCart, items: [{ fk_product: 1, quantity: 3, product: mockProductNormal }] }); + + const mockTx = createMockTx(); + prisma.$transaction.mockImplementation(async (fn) => fn(mockTx)); - it("crea la orden correctamente con dirección válida", async () => { - prisma.carts.findFirst.mockResolvedValue(mockCart); - prisma.stores.findFirst.mockResolvedValue({ - id_store: 10, - addresses: [ - { - latitude: -25.28, - longitude: -57.63, - }, - ], - shipping_zones: [ - { - base_price: 10000, - distance_price: 15000, - }, - ], - }); - prisma.addresses.findFirst.mockResolvedValue(mockAddress); - prisma.shippingZones.findFirst.mockResolvedValue({ - base_price: 10000, - distance_price: 15000, - }); - process.env.ORS_API_KEY = "test-ors-key"; - vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue({ - routes: [ - { - summary: { - distance: 1500, - }, - }, - ], - }), - })); - 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); - prisma.notifications.create.mockResolvedValue({}); - prisma.products.update.mockResolvedValue({}); - prisma.orderItems.findMany.mockResolvedValue([]); - - const result = await createOrderService(1, { - cartId: 1, - addressId: 1, - notes: "entregar en la mañana", - shippingMethod: "standard", - }); - - expect(result).toMatchObject({ - id: 100, - status: "PENDING", - total: 200, - }); - - vi.unstubAllGlobals(); - }); - - 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); - prisma.notifications.create.mockResolvedValue({}); - prisma.products.update.mockResolvedValue({}); - prisma.orderItems.findMany.mockResolvedValue([]); - - const result = await createOrderService(1, { - cartId: 1, - addressId: null, - notes: null, - }); - - expect(result.id).toBe(100); - // No debe haber consultado direcciones - expect(prisma.addresses.findFirst).not.toHaveBeenCalled(); - }); - - // ─── Cálculo de precios históricos ─────────────────────────────────────── + await createOrderService(1, { cartId: 1, addressId: null, notes: null }); - 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); - prisma.notifications.create.mockResolvedValue({}); - prisma.products.update.mockResolvedValue({}); - prisma.orderItems.findMany.mockResolvedValue([]); - - 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({ + const createManyCall = mockTx.orderItems.createMany.mock.calls[0]; + expect(createManyCall[0].data[0]).toMatchObject({ fk_product: 1, quantity: 3, - price: 100, // price normal + price: 100, original_price: 100, is_offer_applied: false, - subtotal: 300, // 100 * 3 + subtotal: 300, }); }); - 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); - prisma.notifications.create.mockResolvedValue({}); - prisma.products.update.mockResolvedValue({}); - prisma.orderItems.findMany.mockResolvedValue([]); + it("crea orden correctamente — valida que createMany reciba price con oferta (80) y subtotal correcto (160)", async () => { + prisma.carts.findFirst.mockResolvedValue({ ...mockCart, items: [{ fk_product: 2, quantity: 2, product: mockProductOffer }] }); + + const mockTx = createMockTx(); + prisma.$transaction.mockImplementation(async (fn) => fn(mockTx)); await createOrderService(1, { cartId: 1, addressId: null, notes: null }); - const itemsCreated = prisma.orderItems.createMany.mock.calls[0][0].data; - expect(itemsCreated[0]).toMatchObject({ + const createManyCall = mockTx.orderItems.createMany.mock.calls[0]; + expect(createManyCall[0].data[0]).toMatchObject({ fk_product: 2, quantity: 2, - price: 80, // offer_price - original_price: 100, // price original + price: 80, + original_price: 100, is_offer_applied: true, - subtotal: 160, // 80 * 2 + subtotal: 160, }); }); - it("lanza ValidationError cuando cartId no es un entero positivo", async () => { - await expect( - createOrderService(1, { cartId: -1, addressId: null, notes: null }) - ).rejects.toThrow(ValidationError); - }); + it("marca carritos previos como ABANDONED y el actual como CHECKED_OUT", async () => { + prisma.carts.findFirst.mockResolvedValue(mockCart); + + const mockTx = createMockTx(); + prisma.$transaction.mockImplementation(async (fn) => fn(mockTx)); - it("lanza ValidationError cuando un producto no tiene stock suficiente", async () => { - prisma.carts.findFirst.mockResolvedValue({ - ...mockCart, - items: [ - { - fk_product: 1, - quantity: 15, // pide más de lo disponible - product: { ...mockProductNormal, quantity: 10 }, - }, - ], - }); + await createOrderService(1, { cartId: 1, addressId: null, notes: null }); - await expect( - createOrderService(1, { cartId: 1, addressId: null, notes: null }) - ).rejects.toThrow(ValidationError); + expect(mockTx.carts.updateMany).toHaveBeenCalledWith({ + where: { fk_user: 1, fk_store: 10, cart_status: "CHECKED_OUT" }, + data: { cart_status: "ABANDONED" }, + }); + expect(mockTx.carts.update).toHaveBeenCalledWith({ + where: { id_cart: 1 }, + data: { cart_status: "CHECKED_OUT" }, + }); }); }); @@ -389,58 +150,23 @@ describe("createOrderService", () => { describe("getOrdersService", () => { beforeEach(() => vi.clearAllMocks()); - it("retorna los pedidos del propio usuario sin consultar tiendas", async () => { + it("retorna pedidos sin consultar tiendas para CUSTOMER", async () => { prisma.orders.findMany.mockResolvedValue([mockOrderFromDB]); - - const result = await getOrdersService(1, 1); // mismo usuario - + const result = await getOrdersService(1, 1); 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 () => { + it("filtra por tienda cuando SELLER consulta", 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([]); + await getOrdersService(2, 1); + expect(prisma.orders.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ fk_store: 10 }) })); }); - 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); + it("lanza ForbiddenError cuando SELLER no tiene tienda común", async () => { + prisma.stores.findFirst.mockResolvedValue(null); + await expect(getOrdersService(2, 1)).rejects.toThrow(ForbiddenError); }); }); @@ -449,76 +175,18 @@ describe("getOrdersService", () => { 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 () => { + it("retorna paginación correcta con total_page calculado", 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) + expect(result).toMatchObject({ total: 25, page: 1, limit: 10, total_page: 3 }); }); - 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); + it("lanza NotFoundError si tienda no existe", async () => { + prisma.stores.findFirst.mockResolvedValue(null); + await expect(getStoreOrdersService(1, 99, {})).rejects.toThrow(NotFoundError); }); }); @@ -527,320 +195,90 @@ describe("getStoreOrdersService", () => { 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.$transaction.mockImplementation(async (fn) => - fn({ - orders: { - update: vi.fn().mockResolvedValue({ ...mockOrderFromDB, fk_store: 10, order_status: "PROCESSING" }), - }, - deliveries: { findFirst: vi.fn().mockResolvedValue({ id_delivery: 1 }) }, - deliveryAssignments: { - findMany: vi.fn().mockResolvedValue([]), - findFirst: vi.fn().mockResolvedValue(null), - create: vi.fn().mockResolvedValue({}), - }, - notifications: { create: vi.fn().mockResolvedValue({}) }, - }) - ); - - const result = await updateOrderStatusService(1, 100, "PROCESSING"); - expect(result.status).toBe("PROCESSING"); - }); - - it("SELLER puede pasar de PENDING a CANCELLED", async () => { + it("SELLER PENDING → PROCESSING: actualiza estado y carts.updateMany/update", async () => { prisma.users.findFirst.mockResolvedValue({ role: "SELLER" }); - prisma.orders.findFirst.mockResolvedValue({ - id_order: 100, - order_status: "PENDING", - fk_store: 10, - }); + prisma.orders.findFirst.mockResolvedValue({ id_order: 100, order_status: "PENDING", fk_store: 10 }); prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); - prisma.$transaction.mockImplementation(async (fn) => - fn({ - orders: { update: vi.fn().mockResolvedValue({ ...mockOrderFromDB, fk_store: 10, order_status: "CANCELLED" }) }, - deliveries: { findFirst: vi.fn() }, - deliveryAssignments: { findFirst: vi.fn(), create: vi.fn() }, - orderItems: { findMany: vi.fn().mockResolvedValue([]) }, - products: { update: vi.fn().mockResolvedValue({}) }, - notifications: { create: vi.fn().mockResolvedValue({}) }, - }) - ); - - const result = await updateOrderStatusService(1, 100, "CANCELLED"); - expect(result.status).toBe("CANCELLED"); - }); - 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, + const mockTx = createMockTx({ + orders: { update: vi.fn().mockResolvedValue({ ...mockOrderFromDB, order_status: "PROCESSING" }) }, }); - prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); - prisma.$transaction.mockImplementation(async (fn) => - fn({ - orders: { update: vi.fn().mockResolvedValue({ ...mockOrderFromDB, fk_store: 10, order_status: "SHIPPED" }) }, - deliveries: { findFirst: vi.fn() }, - deliveryAssignments: { findFirst: vi.fn(), create: vi.fn() }, - notifications: { create: vi.fn().mockResolvedValue({}) }, - }) - ); - - const result = await updateOrderStatusService(1, 100, "SHIPPED"); - expect(result.status).toBe("SHIPPED"); - }); + prisma.$transaction.mockImplementation(async (fn) => fn(mockTx)); - 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 }); + const result = await updateOrderStatusService(1, 100, "PROCESSING"); - await expect( - updateOrderStatusService(1, 100, "DELIVERED") - ).rejects.toThrow(ValidationError); + expect(result.status).toBe("PROCESSING"); + expect(mockTx.orders.update).toHaveBeenCalled(); }); - it("lanza ValidationError cuando el SELLER intenta modificar un pedido ya CANCELLED", async () => { + it("SELLER PENDING → CANCELLED: restaura stock (2 productos)", async () => { prisma.users.findFirst.mockResolvedValue({ role: "SELLER" }); - prisma.orders.findFirst.mockResolvedValue({ - id_order: 100, - order_status: "CANCELLED", - fk_store: 10, - }); + prisma.orders.findFirst.mockResolvedValue({ id_order: 100, order_status: "PENDING", 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, + const mockTx = createMockTx({ + orders: { update: vi.fn().mockResolvedValue({ ...mockOrderFromDB, order_status: "CANCELLED" }) }, + orderItems: { findMany: vi.fn().mockResolvedValue([{ fk_product: 1, quantity: 2 }, { fk_product: 2, quantity: 1 }]) }, }); - prisma.$transaction.mockImplementation(async (fn) => - fn({ - orders: { update: vi.fn().mockResolvedValue({ ...mockOrderFromDB, fk_store: 10, order_status: "DELIVERED" }) }, - deliveries: { findFirst: vi.fn() }, - deliveryAssignments: { findFirst: vi.fn(), create: vi.fn() }, - notifications: { create: vi.fn().mockResolvedValue({}) }, - }) - ); - - const result = await updateOrderStatusService(1, 100, "DELIVERED"); - expect(result.status).toBe("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 }); - prisma.$transaction.mockImplementation(async (fn) => - fn({ - orders: { update: vi.fn().mockResolvedValue({ ...mockOrderFromDB, fk_store: 10, order_status: "CANCELLED" }) }, - deliveries: { findFirst: vi.fn() }, - deliveryAssignments: { findFirst: vi.fn(), create: vi.fn() }, - orderItems: { findMany: vi.fn().mockResolvedValue([]) }, - products: { update: vi.fn().mockResolvedValue({}) }, - notifications: { create: vi.fn().mockResolvedValue({}) }, - }) - ); + prisma.$transaction.mockImplementation(async (fn) => fn(mockTx)); const result = await updateOrderStatusService(1, 100, "CANCELLED"); + expect(result.status).toBe("CANCELLED"); + expect(mockTx.products.update).toHaveBeenCalledTimes(2); }); - it("lanza ValidationError cuando el CUSTOMER intenta cancelar un pedido en PROCESSING", async () => { + it("CUSTOMER PENDING → CANCELLED: restaura stock (1 producto)", async () => { prisma.users.findFirst.mockResolvedValue({ role: "CUSTOMER" }); prisma.orders.findFirst - .mockResolvedValueOnce({ id_order: 100, order_status: "PROCESSING", fk_store: 10 }) + .mockResolvedValueOnce({ id_order: 100, order_status: "PENDING", 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, + const mockTx = createMockTx({ + orders: { update: vi.fn().mockResolvedValue({ ...mockOrderFromDB, order_status: "CANCELLED" }) }, + orderItems: { findMany: vi.fn().mockResolvedValue([{ fk_product: 1, quantity: 2 }]) }, }); + prisma.$transaction.mockImplementation(async (fn) => fn(mockTx)); + + const result = await updateOrderStatusService(1, 100, "CANCELLED"); - await expect( - updateOrderStatusService(1, 100, "PROCESSING") - ).rejects.toThrow(ForbiddenError); + expect(result.status).toBe("CANCELLED"); + expect(mockTx.products.update).toHaveBeenCalledTimes(1); }); - it("SELLER al pasar a PROCESSING no crea asignación automática de delivery", 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 }); + it("DELIVERY SHIPPED → DELIVERED: transición permitida", async () => { + prisma.users.findFirst.mockResolvedValue({ role: "DELIVERY" }); + prisma.orders.findFirst.mockResolvedValue({ id_order: 100, order_status: "SHIPPED", fk_store: 10 }); - const mockAssignmentCreate = vi.fn(); - prisma.$transaction.mockImplementation(async (fn) => { - return fn({ - orders: { - update: vi.fn().mockResolvedValue({ - ...mockOrderFromDB, - order_status: "PROCESSING", - fk_store: 10, - order_items: [], - }), - }, - deliveryAssignments: { - create: mockAssignmentCreate, - }, - }); + const mockTx = createMockTx({ + orders: { update: vi.fn().mockResolvedValue({ ...mockOrderFromDB, order_status: "DELIVERED" }) }, }); + prisma.$transaction.mockImplementation(async (fn) => fn(mockTx)); - const result = await updateOrderStatusService(1, 100, "PROCESSING"); + const result = await updateOrderStatusService(1, 100, "DELIVERED"); - expect(result.status).toBe("PROCESSING"); - expect(mockAssignmentCreate).not.toHaveBeenCalled(); + expect(result.status).toBe("DELIVERED"); }); - it("restaura el stock al cancelar un pedido (SELLER cancela desde PENDING)", async () => { + it("SELLER SHIPPED → DELIVERED: transición NO permitida", async () => { prisma.users.findFirst.mockResolvedValue({ role: "SELLER" }); - prisma.orders.findFirst.mockResolvedValue({ - id_order: 100, - order_status: "PENDING", - fk_store: 10, - }); + prisma.orders.findFirst.mockResolvedValue({ id_order: 100, order_status: "SHIPPED", fk_store: 10 }); prisma.stores.findFirst.mockResolvedValue({ id_store: 10 }); - const mockProductsUpdate = vi.fn().mockResolvedValue({}); - const mockOrderItemsFindMany = vi.fn().mockResolvedValue([ - { fk_product: 1, quantity: 2 }, - { fk_product: 2, quantity: 1 }, - ]); - - prisma.$transaction.mockImplementation(async (fn) => - fn({ - orders: { update: vi.fn().mockResolvedValue({ ...mockOrderFromDB, fk_store: 10, order_status: "CANCELLED" }) }, - orderItems: { findMany: mockOrderItemsFindMany }, - products: { update: mockProductsUpdate }, - deliveries: { findFirst: vi.fn() }, - deliveryAssignments: { findFirst: vi.fn(), create: vi.fn() }, - notifications: { create: vi.fn().mockResolvedValue({}) }, - }) - ); - - const result = await updateOrderStatusService(1, 100, "CANCELLED"); - - expect(result.status).toBe("CANCELLED"); - expect(mockOrderItemsFindMany).toHaveBeenCalled(); - expect(mockProductsUpdate).toHaveBeenCalledTimes(2); + await expect(updateOrderStatusService(1, 100, "DELIVERED")).rejects.toThrow(ValidationError); }); - it("restaura el stock al cancelar un pedido (CUSTOMER cancela desde PENDING)", async () => { + it("CUSTOMER PROCESSING → CANCELLED: NO permitida", async () => { prisma.users.findFirst.mockResolvedValue({ role: "CUSTOMER" }); prisma.orders.findFirst - .mockResolvedValueOnce({ id_order: 100, order_status: "PENDING", fk_store: 10 }) + .mockResolvedValueOnce({ id_order: 100, order_status: "PROCESSING", fk_store: 10 }) .mockResolvedValueOnce({ id_order: 100 }); - const mockProductsUpdate = vi.fn().mockResolvedValue({}); - const mockOrderItemsFindMany = vi.fn().mockResolvedValue([ - { fk_product: 1, quantity: 2 }, - ]); - - prisma.$transaction.mockImplementation(async (fn) => - fn({ - orders: { update: vi.fn().mockResolvedValue({ ...mockOrderFromDB, fk_store: 10, order_status: "CANCELLED" }) }, - orderItems: { findMany: mockOrderItemsFindMany }, - products: { update: mockProductsUpdate }, - deliveries: { findFirst: vi.fn() }, - deliveryAssignments: { findFirst: vi.fn(), create: vi.fn() }, - notifications: { create: vi.fn().mockResolvedValue({}) }, - }) - ); - - const result = await updateOrderStatusService(1, 100, "CANCELLED"); + await expect(updateOrderStatusService(1, 100, "CANCELLED")).rejects.toThrow(ValidationError); + }); - expect(result.status).toBe("CANCELLED"); - expect(mockProductsUpdate).toHaveBeenCalledTimes(1); + it("lanza NotFoundError si usuario no existe", async () => { + prisma.users.findFirst.mockResolvedValue(null); + await expect(updateOrderStatusService(1, 100, "PROCESSING")).rejects.toThrow(NotFoundError); }); }); \ No newline at end of file