Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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');
4 changes: 2 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src/config/swagger.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
// /admin
adminCategorySchemas,
adminBannerSchemas,
// /users
cartSchemas,
} from "../docs/schemas/index.js";

const options = {
Expand Down Expand Up @@ -68,6 +70,8 @@ const options = {
},
schemas: {
...commonSchemas,
// /users/
...cartSchemas,
// /commerce/
...addressSchemas,
...storeSchemas,
Expand Down
89 changes: 89 additions & 0 deletions src/docs/schemas/users/cart.schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// src/docs/schemas/users/cart.schema.js

export const cartSchemas = {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"
}
}
}
};
7 changes: 5 additions & 2 deletions src/modules/users/cart/cart.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,27 @@ 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,
fk_store: storeId,
cart_status: "ACTIVE"
}
},
update: {}, // no actualiza nada si ya existe
update: {},
create: {
fk_user: userId,
fk_store: storeId,
cart_status: "ACTIVE",
status: true
}
});

return cart;
};

const getCartWithItems = async (cartId) => {
Expand Down
14 changes: 12 additions & 2 deletions src/modules/users/orders/order.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 8 additions & 14 deletions tests/unit/cart/cart.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}),
},
Expand All @@ -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);
});

Expand All @@ -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({}),
},
};
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -394,7 +393,6 @@ describe("addCartItemService", () => {
});
prisma.carts.findUnique.mockResolvedValue(mockCartFull);

// sin quantity
const result = await addCartItemService(1, 1, { productId: 1 });

expect(result).toBeDefined();
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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 },
});
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading