From f6d5757236a76694cf6eaef8dca0623c4d90e714 Mon Sep 17 00:00:00 2001 From: Lianyang1234 Date: Wed, 25 Mar 2026 22:47:07 -0300 Subject: [PATCH] Filtro por ofertas --- src/docs/schemas/commerce/product.schema.js | 24 +- src/lib/product-pricing.js | 25 ++ .../commerce/commerces/store.service.js | 97 +++++++- .../commerce/products/product.controller.js | 7 + .../commerce/products/product.routes.js | 27 ++- .../commerce/products/product.service.js | 189 ++++++++++++++- .../commerce/filter-store-products.dto.js | 10 +- .../filter-store-products.response.js | 8 +- .../dtos/products/product.request.dto.ts | 65 +++-- .../dtos/products/product.response.dto.ts | 13 +- .../users/wishlist/wishlist.service.js | 32 ++- tests/e2e/products.test.js | 228 ++++++++++++++---- tests/e2e/stores.test.js | 20 +- 13 files changed, 615 insertions(+), 130 deletions(-) create mode 100644 src/lib/product-pricing.js diff --git a/src/docs/schemas/commerce/product.schema.js b/src/docs/schemas/commerce/product.schema.js index f6eaa9b..9a88318 100644 --- a/src/docs/schemas/commerce/product.schema.js +++ b/src/docs/schemas/commerce/product.schema.js @@ -6,6 +6,8 @@ export const productSchemas = { properties: { name: { type: "string", maxLength: 100, example: "Auriculares Bluetooth" }, price: { type: "number", example: 150000 }, + offerPrice: { type: "number", nullable: true, example: 120000 }, + isOffer: { type: "boolean", example: true }, categoryId: { type: "integer", example: 3 }, description: { type: "string", nullable: true, example: "Auriculares de alta calidad" }, quantity: { type: "integer", nullable: true, example: 50 }, @@ -23,6 +25,8 @@ export const productSchemas = { properties: { name: { type: "string", maxLength: 100, example: "Auriculares Bluetooth Pro" }, price: { type: "number", example: 200000 }, + offerPrice: { type: "number", nullable: true, example: 150000 }, + isOffer: { type: "boolean", example: true }, categoryId: { type: "integer", example: 3 }, description: { type: "string", nullable: true }, quantity: { type: "integer", nullable: true }, @@ -38,7 +42,10 @@ export const productSchemas = { id: { type: "integer", example: 1 }, name: { type: "string", example: "Auriculares Bluetooth" }, description: { type: "string", nullable: true }, - price: { type: "number", example: 150000 }, + price: { type: "number", example: 120000 }, + originalPrice: { type: "number", example: 150000 }, + offerPrice: { type: "number", nullable: true, example: 120000 }, + isOffer: { type: "boolean", example: true }, quantity: { type: "integer", nullable: true, example: 50 }, categoryId: { type: "integer", example: 3 }, category: { @@ -83,7 +90,10 @@ export const productSchemas = { id: { type: "integer", example: 1 }, name: { type: "string", example: "Auriculares Bluetooth" }, description: { type: "string", nullable: true }, - price: { type: "number", example: 150000 }, + price: { type: "number", example: 120000 }, + original_price: { type: "number", example: 150000 }, + offer_price: { type: "number", nullable: true, example: 120000 }, + is_offer: { type: "boolean", example: true }, store: { type: "object", nullable: true, @@ -124,7 +134,10 @@ export const productSchemas = { productId: { type: "integer", example: 1 }, name: { type: "string", example: "Auriculares Bluetooth" }, description: { type: "string", nullable: true }, - price: { type: "number", example: 150000 }, + price: { type: "number", example: 120000 }, + originalPrice: { type: "number", example: 150000 }, + offerPrice: { type: "number", nullable: true, example: 120000 }, + isOffer: { type: "boolean", example: true }, store: { type: "object", nullable: true, @@ -144,6 +157,9 @@ export const productSchemas = { name: { type: "string", example: "Auriculares Bluetooth" }, description: { type: "string", nullable: true }, price: { type: "number", example: 120000 }, + originalPrice: { type: "number", example: 150000 }, + offerPrice: { type: "number", nullable: true, example: 120000 }, + isOffer: { type: "boolean", example: true }, store: { type: "object", nullable: true, @@ -167,4 +183,4 @@ export const productSchemas = { } } } -}; \ No newline at end of file +}; diff --git a/src/lib/product-pricing.js b/src/lib/product-pricing.js new file mode 100644 index 0000000..d88cfd6 --- /dev/null +++ b/src/lib/product-pricing.js @@ -0,0 +1,25 @@ +const toNullableNumber = (value) => { + if (value === undefined || value === null) { + return null; + } + + return Number(value); +}; + +export const getOriginalProductPrice = (product) => Number(product.price); + +export const getOfferProductPrice = (product) => toNullableNumber(product.offer_price); + +export const getEffectiveProductPrice = (product) => { + const originalPrice = getOriginalProductPrice(product); + const offerPrice = getOfferProductPrice(product); + + return product?.is_offer && offerPrice !== null ? offerPrice : originalPrice; +}; + +export const getProductPricing = (product) => ({ + price: getEffectiveProductPrice(product), + originalPrice: getOriginalProductPrice(product), + offerPrice: getOfferProductPrice(product), + isOffer: Boolean(product?.is_offer) +}); diff --git a/src/modules/commerce/commerces/store.service.js b/src/modules/commerce/commerces/store.service.js index 51d8bc8..1668091 100644 --- a/src/modules/commerce/commerces/store.service.js +++ b/src/modules/commerce/commerces/store.service.js @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { prisma } from "../../../lib/prisma.js"; +import { getProductPricing } from "../../../lib/product-pricing.js"; import { validateStoreCategoryService } from "../store-categories/store-category.service.js"; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -43,8 +44,10 @@ const STORE_RESPONSE_SELECT = { id_product: true, name: true, price: true, + offer_price: true, quantity: true, visible: true, + is_offer: true, product_category: { select: { id_product_category: true, @@ -149,6 +152,52 @@ const validateEmailField = (value) => { return normalizedEmail; }; +const parseBooleanField = (value, fieldName) => { + if (typeof value === "boolean") { + return value; + } + + const normalizedValue = String(value).trim().toLowerCase(); + + if (normalizedValue === "true" || normalizedValue === "1") { + return true; + } + + if (normalizedValue === "false" || normalizedValue === "0") { + return false; + } + + throw { + status: 400, + message: `${fieldName} debe ser booleano` + }; +}; + +const mapStoreProductPricing = (product) => { + const pricing = getProductPricing(product); + + return { + ...product, + price: pricing.price, + original_price: pricing.originalPrice, + offer_price: pricing.offerPrice, + is_offer: pricing.isOffer + }; +}; + +const mapStoreWithPricedProducts = (store) => { + if (!store) { + return store; + } + + return { + ...store, + products: Array.isArray(store.products) + ? store.products.map(mapStoreProductPricing) + : [] + }; +}; + // valida que el usuario autenticado sea el propietario del comercio solicitado export const getAuthorizedStoreOwnerService = async ( authenticatedUserId, @@ -571,7 +620,7 @@ export const updateStoreService = async ( await deletePreviousStoreLogoFromStorage(store.logo, updatedStore.logo); - return updatedStore; + return mapStoreWithPricedProducts(updatedStore); } catch (error) { if (error.code === "P2002") { throw { @@ -622,8 +671,10 @@ export const getStoreByIdService = async (id) => { id_product: true, name: true, price: true, + offer_price: true, quantity: true, visible: true, + is_offer: true, product_category: { select: { id_product_category: true, name: true } } @@ -641,7 +692,7 @@ export const getStoreByIdService = async (id) => { throw { status: 404, message: "Comercio no encontrado" }; } - return store; + return mapStoreWithPricedProducts(store); } catch (error) { if (error.status) { @@ -688,8 +739,10 @@ export const getAllProductsByStoreService = async (id) => { name: true, description: true, price: true, + offer_price: true, quantity: true, visible: true, + is_offer: true, created_at: true, product_category: { select: { id_product_category: true, name: true }, @@ -705,7 +758,7 @@ export const getAllProductsByStoreService = async (id) => { }; } - return products; + return products.map(mapStoreProductPricing); } catch (error) { if (error.status) { @@ -743,10 +796,13 @@ export const filterStoreProductsService = async (id, filters, pagination) => { name, category, visible, + available, minPrice, maxPrice, price_min, price_max, + isOffer, + is_offer, sortBy, sortOrder } = filters; @@ -764,8 +820,17 @@ export const filterStoreProductsService = async (id, filters, pagination) => { whereConditions.fk_product_category = Number(category); } - if (visible !== undefined && visible !== null) { - whereConditions.visible = visible; + const resolvedVisible = visible ?? available; + if (resolvedVisible !== undefined && resolvedVisible !== null) { + whereConditions.visible = resolvedVisible; + } + + const resolvedIsOffer = isOffer ?? is_offer; + if (resolvedIsOffer !== undefined && resolvedIsOffer !== null) { + whereConditions.is_offer = + typeof resolvedIsOffer === "boolean" + ? resolvedIsOffer + : parseBooleanField(resolvedIsOffer, "isOffer"); } const resolvedMinPrice = minPrice ?? price_min; @@ -782,31 +847,43 @@ export const filterStoreProductsService = async (id, filters, pagination) => { }; } - const products = await prisma.products.findMany({ + const [totalProducts, products] = await Promise.all([ + prisma.products.count({ + where: whereConditions + }), + prisma.products.findMany({ where: whereConditions, + skip: pagination?.skip ?? 0, + take: pagination?.limit ?? 20, select: { id_product: true, name: true, description: true, price: true, + offer_price: true, quantity: true, visible: true, + is_offer: true, created_at: true, product_category: { select: { id_product_category: true, name: true }, }, }, orderBy: { [sortBy || "created_at"]: sortOrder === "asc" ? "asc" : "desc" } - }); + }) + ]); - if (!products || products.length === 0) { + if (totalProducts === 0) { throw { status: 404, message: "No se encontraron productos para esta tienda con los filtros aplicados" }; } - return products; + return { + products: products.map(mapStoreProductPricing), + totalProducts + }; } catch (error) { if (error.status) { @@ -890,4 +967,4 @@ export const getStoresService = async (filters = {}) => { }); return stores; -}; \ No newline at end of file +}; diff --git a/src/modules/commerce/products/product.controller.js b/src/modules/commerce/products/product.controller.js index 49204cc..fb35ebf 100644 --- a/src/modules/commerce/products/product.controller.js +++ b/src/modules/commerce/products/product.controller.js @@ -137,6 +137,13 @@ export const compareProducts = async (request, response) => { name: p.name, description: p.description, price: Number(p.price), + originalPrice: + p.original_price === undefined ? Number(p.price) : Number(p.original_price), + offerPrice: + p.offer_price === undefined || p.offer_price === null + ? null + : Number(p.offer_price), + isOffer: Boolean(p.is_offer), store: p.store ? { id: p.store.id_store, diff --git a/src/modules/commerce/products/product.routes.js b/src/modules/commerce/products/product.routes.js index 0377e05..c82bf2d 100644 --- a/src/modules/commerce/products/product.routes.js +++ b/src/modules/commerce/products/product.routes.js @@ -33,7 +33,7 @@ const router = Router(); * schema: * $ref: '#/components/schemas/ProductResponse' * 400: - * description: Campos inválidos o faltantes + * description: Campos invalidos o faltantes * content: * application/json: * schema: @@ -82,7 +82,7 @@ router.post("/", authenticate, createProduct); * schema: * $ref: '#/components/schemas/ProductResponse' * 400: - * description: Sin campos para actualizar o campos inválidos + * description: Sin campos para actualizar o campos invalidos * content: * application/json: * schema: @@ -112,7 +112,7 @@ router.put("/:id", authenticate, updateProduct); * @swagger * /products/{id}: * delete: - * summary: Eliminar un producto (borrado lógico) + * summary: Eliminar un producto (borrado logico) * tags: [Products] * security: * - cookieAuth: [] @@ -158,24 +158,29 @@ router.delete("/:id", authenticate, deleteProduct); * name: search * schema: * type: string - * description: Buscar por nombre o descripción + * description: Buscar por nombre o descripcion * - in: query * name: categoryId * schema: * type: integer - * description: Filtrar por ID de categoría + * description: Filtrar por ID de categoria + * - in: query + * name: isOffer + * schema: + * type: boolean + * description: Filtrar productos en oferta (`true`) o fuera de oferta (`false`) * - in: query * name: page * schema: * type: integer * default: 1 - * description: Número de página + * description: Numero de pagina * - in: query * name: limit * schema: * type: integer * default: 20 - * description: Cantidad de productos por página (máx. 100) + * description: Cantidad de productos por pagina (max. 100) * responses: * 200: * description: Lista paginada de productos @@ -184,7 +189,7 @@ router.delete("/:id", authenticate, deleteProduct); * schema: * $ref: '#/components/schemas/ProductSearchResponse' * 400: - * description: Parámetros inválidos + * description: Parametros invalidos * content: * application/json: * schema: @@ -203,12 +208,12 @@ router.get("/", getProductsSearch); * name: search * schema: * type: string - * description: Término de búsqueda del producto a comparar + * description: Termino de busqueda del producto a comparar * - in: query * name: categoryId * schema: * type: integer - * description: Filtrar por categoría + * description: Filtrar por categoria * responses: * 200: * description: Producto base y ofertas similares en otras tiendas @@ -254,4 +259,4 @@ router.get("/compare/search", compareProducts); */ router.get("/:id", getProductById); -export default router; \ No newline at end of file +export default router; diff --git a/src/modules/commerce/products/product.service.js b/src/modules/commerce/products/product.service.js index b69c88f..75719a0 100644 --- a/src/modules/commerce/products/product.service.js +++ b/src/modules/commerce/products/product.service.js @@ -1,4 +1,10 @@ import { prisma } from "../../../lib/prisma.js"; +import { + getEffectiveProductPrice, + getOfferProductPrice, + getOriginalProductPrice, + getProductPricing +} from "../../../lib/product-pricing.js"; import { validateProductCategoryService } from "../../global/categories/product-categories/product-category.service.js"; import { parseProductTagIdsService, @@ -17,10 +23,12 @@ const PRODUCT_RESPONSE_SELECT = { name: true, description: true, price: true, + offer_price: true, quantity: true, fk_product_category: true, fk_store: true, visible: true, + is_offer: true, created_at: true, updated_at: true, product_category: { @@ -84,6 +92,19 @@ const sanitizePaginationValue = (value, fallback) => { return Math.floor(parsedValue); }; +const hasOwnProperty = (payload, fieldName) => + Object.prototype.hasOwnProperty.call(payload ?? {}, fieldName); + +const getFirstDefinedValue = (payload, fieldNames) => { + for (const fieldName of fieldNames) { + if (hasOwnProperty(payload, fieldName)) { + return payload[fieldName]; + } + } + + return undefined; +}; + const validateRequiredStringField = (value, fieldName, maxLength = null) => { const normalizedValue = value?.toString().trim(); @@ -127,6 +148,59 @@ const validatePriceField = (value) => { return parsedPrice; }; +const parseOptionalPriceField = (value, fieldName) => { + if (value === undefined) { + return undefined; + } + + if (value === null || value === "") { + return null; + } + + const parsedPrice = Number(value); + if (!Number.isFinite(parsedPrice) || parsedPrice <= 0) { + throw { status: 400, message: `${fieldName} debe ser mayor a 0` }; + } + + return parsedPrice; +}; + +const parseBooleanField = (value, fieldName) => { + if (typeof value === "boolean") { + return value; + } + + if (typeof value === "number") { + if (value === 1) { + return true; + } + + if (value === 0) { + return false; + } + } + + const normalizedValue = String(value).trim().toLowerCase(); + + if (normalizedValue === "true" || normalizedValue === "1") { + return true; + } + + if (normalizedValue === "false" || normalizedValue === "0") { + return false; + } + + throw { status: 400, message: `${fieldName} debe ser booleano` }; +}; + +const parseOptionalBooleanField = (value, fieldName) => { + if (value === undefined || value === null || value === "") { + return undefined; + } + + return parseBooleanField(value, fieldName); +}; + const parseQuantityField = (value) => { if (value === undefined) { return undefined; @@ -187,15 +261,68 @@ const parseVisibilityOverride = (payload) => { return null; }; +const resolveOfferPayload = (payload, currentState = {}) => { + const hasIsOfferField = + hasOwnProperty(payload, "isOffer") || hasOwnProperty(payload, "is_offer"); + const hasOfferPriceField = + hasOwnProperty(payload, "offerPrice") || hasOwnProperty(payload, "offer_price"); + + const parsedIsOffer = parseOptionalBooleanField( + getFirstDefinedValue(payload, ["isOffer", "is_offer"]), + "isOffer" + ); + const parsedOfferPrice = parseOptionalPriceField( + getFirstDefinedValue(payload, ["offerPrice", "offer_price"]), + "offerPrice" + ); + + let isOffer = Boolean(currentState.is_offer); + let offerPrice = + currentState.offer_price === undefined ? null : currentState.offer_price; + + if (parsedIsOffer !== undefined) { + isOffer = parsedIsOffer; + } + + if (hasOfferPriceField) { + offerPrice = parsedOfferPrice; + } + + if (!hasIsOfferField && hasOfferPriceField && parsedOfferPrice !== null) { + isOffer = true; + } + + if (hasIsOfferField && parsedIsOffer === false && !hasOfferPriceField) { + offerPrice = null; + } + + if (isOffer && (offerPrice === null || offerPrice === undefined)) { + throw { + status: 400, + message: "offerPrice es requerido cuando isOffer=true" + }; + } + + return { + hasOfferData: hasIsOfferField || hasOfferPriceField, + is_offer: isOffer, + offer_price: offerPrice + }; +}; + const mapProductResponse = (product) => { const ratings = product.product_reviews?.map(r => r.rating).filter(r => r !== null) || []; const lifecycleStatus = product.visible ? "active" : "pending"; + const pricing = getProductPricing(product); return { id: product.id_product, name: product.name, description: product.description, - price: Number(product.price), + price: pricing.price, + originalPrice: pricing.originalPrice, + offerPrice: pricing.offerPrice, + isOffer: pricing.isOffer, quantity: product.quantity, categoryId: product.fk_product_category, category: product.product_category @@ -274,6 +401,8 @@ const getExistingProductForUpdateService = async (productId) => { select: { id_product: true, fk_store: true, + offer_price: true, + is_offer: true, status: true } }); @@ -310,6 +439,7 @@ const buildCreateProductData = async (payload) => { const description = normalizeOptionalStringField(payload?.description) ?? null; const visibilityOverride = parseVisibilityOverride({ visible: payload?.visible }); const tagIds = parseProductTagIdsService(payload?.tags); + const { is_offer, offer_price } = resolveOfferPayload(payload); await validateProductTagsService(tagIds); @@ -318,16 +448,23 @@ const buildCreateProductData = async (payload) => { name, description, price, + offer_price, quantity: quantity ?? null, - fk_product_category: categoryId + fk_product_category: categoryId, + is_offer }, tagIds, visibilityOverride }; }; -const buildUpdateProductData = async (payload) => { +const buildUpdateProductData = async (payload, existingProduct) => { const dataToUpdate = {}; + const hasOfferFields = + hasOwnProperty(payload, "isOffer") || + hasOwnProperty(payload, "is_offer") || + hasOwnProperty(payload, "offerPrice") || + hasOwnProperty(payload, "offer_price"); if (payload?.name !== undefined) { dataToUpdate.name = validateRequiredStringField(payload.name, "name", 100); @@ -349,6 +486,12 @@ const buildUpdateProductData = async (payload) => { dataToUpdate.quantity = parseQuantityField(payload.quantity); } + if (hasOfferFields) { + const offerPayload = resolveOfferPayload(payload, existingProduct); + dataToUpdate.is_offer = offerPayload.is_offer; + dataToUpdate.offer_price = offerPayload.offer_price; + } + const visibilityOverride = parseVisibilityOverride(payload); if (visibilityOverride !== null) { dataToUpdate.visible = visibilityOverride; @@ -508,7 +651,7 @@ export const updateProductService = async ( const { dataToUpdate, nextTagIds - } = await buildUpdateProductData(payload); + } = await buildUpdateProductData(payload, existingProduct); const updatedProduct = await prisma.$transaction(async (tx) => { if (Object.keys(dataToUpdate).length > 0) { @@ -538,6 +681,7 @@ export const updateProductService = async ( export const getProductsSearchService = async (filters) => { const search = filters.search?.toString().trim(); const categoryIdRaw = filters.categoryId ?? filters.category_id ?? filters.fk_product_category; + const isOfferRaw = filters.isOffer ?? filters.is_offer; //Paginacion const page = sanitizePaginationValue(filters.page, DEFAULT_PRODUCTS_PAGE); @@ -561,6 +705,10 @@ export const getProductsSearchService = async (filters) => { where.fk_product_category = categoryId; } + if (isOfferRaw !== undefined && isOfferRaw !== null && String(isOfferRaw).trim() !== "") { + where.is_offer = parseBooleanField(isOfferRaw, "isOffer"); + } + //si se le pasa un search, se busca en name y description if (search) { where.OR = [ @@ -601,6 +749,8 @@ export const getProductsSearchService = async (filters) => { name: true, description: true, price: true, + offer_price: true, + is_offer: true, store: { select: { id_store: true, @@ -612,13 +762,28 @@ export const getProductsSearchService = async (filters) => { ]); return { - products, - pagination: { - totalProducts, - page, - limit, - totalPages: Math.ceil(totalProducts/limit) - }}; + products: products.map((product) => ({ + id_product: product.id_product, + name: product.name, + description: product.description, + price: getEffectiveProductPrice(product), + original_price: getOriginalProductPrice(product), + offer_price: getOfferProductPrice(product), + is_offer: Boolean(product.is_offer), + store: product.store + ? { + id_store: product.store.id_store, + name: product.store.name + } + : null + })), + pagination: { + totalProducts, + page, + limit, + totalPages: Math.ceil(totalProducts / limit) + } + }; }; export const deleteProductService = async (authenticatedUserId, productId) => { @@ -653,4 +818,4 @@ export const getProductByIdService = async (id) => { } return mapProductResponse(product); -}; \ No newline at end of file +}; diff --git a/src/modules/global/dtos/commerce/filter-store-products.dto.js b/src/modules/global/dtos/commerce/filter-store-products.dto.js index d5e545e..db388a2 100644 --- a/src/modules/global/dtos/commerce/filter-store-products.dto.js +++ b/src/modules/global/dtos/commerce/filter-store-products.dto.js @@ -41,6 +41,14 @@ export const FilterStoreProductsDTO = z.object({ .optional() ), + isOffer: z.preprocess( + emptyToUndefined, + z + .enum(["true", "false", "1", "0"]) + .transform((v) => v === "true" || v === "1") + .optional() + ), + sortBy: z.preprocess( emptyToUndefined, z @@ -66,4 +74,4 @@ export const FilterStoreProductsDTO = z.object({ data.price_max === undefined || data.price_min <= data.price_max, { message: "price_min no puede ser mayor que price_max", path: ["price_min"] } -); \ No newline at end of file +); diff --git a/src/modules/global/dtos/commerce/filter-store-products.response.js b/src/modules/global/dtos/commerce/filter-store-products.response.js index 9394d1b..0fee9eb 100644 --- a/src/modules/global/dtos/commerce/filter-store-products.response.js +++ b/src/modules/global/dtos/commerce/filter-store-products.response.js @@ -6,6 +6,12 @@ export class StoreProductItemDTO { this.name = data.name; this.description = data.description ?? null; this.price = Number(data.price); + this.original_price = Number(data.original_price ?? data.price); + this.offer_price = + data.offer_price === null || data.offer_price === undefined + ? null + : Number(data.offer_price); + this.is_offer = Boolean(data.is_offer); this.quantity = data.quantity ?? null; this.visible = data.visible; this.created_at = data.created_at; @@ -39,4 +45,4 @@ export class StoreProductsPageDTO extends PaginatedResponseDTO { static from(items, totalCount, page, limit) { return new StoreProductsPageDTO(items, totalCount, page, limit); } -} \ No newline at end of file +} diff --git a/src/modules/global/dtos/products/product.request.dto.ts b/src/modules/global/dtos/products/product.request.dto.ts index bed506e..1980690 100644 --- a/src/modules/global/dtos/products/product.request.dto.ts +++ b/src/modules/global/dtos/products/product.request.dto.ts @@ -1,20 +1,32 @@ import { z } from "zod"; -// ─── CREATE ────────────────────────────────────────────────────── +const booleanish = z.union([ + z.boolean(), + z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1") +]); + export const CreateProductDTO = z.object({ name: z .string({ error: "name es requerido" }) - .min(1, "name no puede estar vacío") + .min(1, "name no puede estar vacio") .max(100, "name no puede superar 100 caracteres"), price: z .number({ error: "price es requerido" }) .positive("price debe ser mayor a 0"), + offerPrice: z + .number() + .positive("offerPrice debe ser mayor a 0") + .nullable() + .optional(), + + isOffer: booleanish.optional(), + categoryId: z .number({ error: "categoryId es requerido" }) .int("categoryId debe ser entero") - .positive("categoryId debe ser un ID válido"), + .positive("categoryId debe ser un ID valido"), description: z .string() @@ -35,7 +47,9 @@ export const CreateProductDTO = z.object({ z.string().transform((val) => val.split(",").map((v) => { const n = Number(v.trim()); - if (!Number.isInteger(n) || n <= 0) throw new Error("tag inválido"); + if (!Number.isInteger(n) || n <= 0) { + throw new Error("tag invalido"); + } return n; }) ) @@ -43,34 +57,36 @@ export const CreateProductDTO = z.object({ .optional() .default([]), - visible: z - .union([ - z.boolean(), - z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1") - ]) - .optional() + visible: booleanish.optional() }); export type CreateProductDTOType = z.infer; -// ─── UPDATE ────────────────────────────────────────────────────── export const UpdateProductDTO = z .object({ name: z .string() - .min(1, "name no puede estar vacío") + .min(1, "name no puede estar vacio") .max(100, "name no puede superar 100 caracteres") .optional(), price: z - .number({ error: "price debe ser número" }) + .number({ error: "price debe ser numero" }) .positive("price debe ser mayor a 0") .optional(), + offerPrice: z + .number() + .positive("offerPrice debe ser mayor a 0") + .nullable() + .optional(), + + isOffer: booleanish.optional(), + categoryId: z - .number({ error: "categoryId debe ser número" }) + .number({ error: "categoryId debe ser numero" }) .int() - .positive("categoryId debe ser un ID válido") + .positive("categoryId debe ser un ID valido") .optional(), description: z @@ -92,19 +108,16 @@ export const UpdateProductDTO = z z.string().transform((val) => val.split(",").map((v) => { const n = Number(v.trim()); - if (!Number.isInteger(n) || n <= 0) throw new Error("tag inválido"); + if (!Number.isInteger(n) || n <= 0) { + throw new Error("tag invalido"); + } return n; }) ) ]) .optional(), - visible: z - .union([ - z.boolean(), - z.enum(["true", "false", "1", "0"]).transform((v) => v === "true" || v === "1") - ]) - .optional() + visible: booleanish.optional() }) .refine((data) => Object.values(data).some((v) => v !== undefined), { message: "Debe enviar al menos un campo para actualizar" @@ -112,7 +125,6 @@ export const UpdateProductDTO = z export type UpdateProductDTOType = z.infer; -// ─── FILTER ────────────────────────────────────────────────────── export const FilterProductDTO = z .object({ name: z.string().optional(), @@ -120,7 +132,7 @@ export const FilterProductDTO = z categoryId: z .string() .transform(Number) - .pipe(z.number().int().positive("categoryId debe ser un ID válido")) + .pipe(z.number().int().positive("categoryId debe ser un ID valido")) .optional(), visible: z @@ -128,6 +140,11 @@ export const FilterProductDTO = z .transform((v) => v === "true") .optional(), + isOffer: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional(), + minPrice: z .string() .transform(Number) diff --git a/src/modules/global/dtos/products/product.response.dto.ts b/src/modules/global/dtos/products/product.response.dto.ts index c2ed45a..7d99bd9 100644 --- a/src/modules/global/dtos/products/product.response.dto.ts +++ b/src/modules/global/dtos/products/product.response.dto.ts @@ -27,6 +27,9 @@ export class ProductResponseDTO extends BaseResponseDTO { name: string; description: string | null; price: number; + originalPrice: number; + offerPrice: number | null; + isOffer: boolean; quantity: number | null; visible: boolean; fk_store: number; @@ -42,7 +45,15 @@ export class ProductResponseDTO extends BaseResponseDTO { this.id_product = data.id_product; this.name = data.name; this.description = data.description ?? null; - this.price = Number(data.price); + this.originalPrice = Number(data.price); + this.offerPrice = + data.offer_price === null || data.offer_price === undefined + ? null + : Number(data.offer_price); + this.isOffer = Boolean(data.is_offer); + this.price = this.isOffer && this.offerPrice !== null + ? this.offerPrice + : this.originalPrice; this.quantity = data.quantity ?? null; this.visible = data.visible; this.fk_store = data.fk_store; diff --git a/src/modules/users/wishlist/wishlist.service.js b/src/modules/users/wishlist/wishlist.service.js index b22d2ba..381d553 100644 --- a/src/modules/users/wishlist/wishlist.service.js +++ b/src/modules/users/wishlist/wishlist.service.js @@ -1,4 +1,5 @@ import { prisma } from "../../../lib/prisma.js"; +import { getProductPricing } from "../../../lib/product-pricing.js"; import { ForbiddenError, NotFoundError, ValidationError } from "../../../lib/errors.js"; import { parsePositiveInteger } from "../../../lib/validators.js"; @@ -21,15 +22,22 @@ const getOrCreateActiveWishlist = async (userId) => { const mapWishlistResponse = (wishlist) => ({ id: wishlist.id_wishlist, name: wishlist.name, - items: wishlist.wishlist_items.map((item) => ({ - id: item.id_wishlist_item, - quantity: item.quantity, - product: { - id: item.product.id_product, - name: item.product.name, - price: Number(item.product.price) - } - })) + items: wishlist.wishlist_items.map((item) => { + const pricing = getProductPricing(item.product); + + return { + id: item.id_wishlist_item, + quantity: item.quantity, + product: { + id: item.product.id_product, + name: item.product.name, + price: pricing.price, + originalPrice: pricing.originalPrice, + offerPrice: pricing.offerPrice, + isOffer: pricing.isOffer + } + }; + }) }); const getWishlistWithItems = async (wishlistId) => { @@ -47,7 +55,9 @@ const getWishlistWithItems = async (wishlistId) => { select: { id_product: true, name: true, - price: true + price: true, + offer_price: true, + is_offer: true } } } @@ -219,4 +229,4 @@ export const updateWishlistItemQuantityService = async ( const updated = await getWishlistWithItems(wishlist.id_wishlist); return mapWishlistResponse(updated); -}; \ No newline at end of file +}; diff --git a/tests/e2e/products.test.js b/tests/e2e/products.test.js index 0c08922..9ef7acf 100644 --- a/tests/e2e/products.test.js +++ b/tests/e2e/products.test.js @@ -1,60 +1,76 @@ -import { vi, describe, it, expect, beforeEach } from "vitest"; +import { vi, describe, it, expect, beforeEach, beforeAll } from "vitest"; import request from "supertest"; +import jwt from "jsonwebtoken"; import app from "../../src/app.js"; import { prisma } from "../../src/lib/prisma.js"; -// Mock completo de Prisma para evitar conexión a BD en tests vi.mock("../../src/lib/prisma.js", () => ({ prisma: { products: { count: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), - findUnique: vi.fn(), + findUnique: vi.fn() }, users: { - findUnique: vi.fn(), + findUnique: vi.fn() }, productCategories: { - findUnique: vi.fn(), + findUnique: vi.fn() }, productTags: { - findMany: vi.fn(), + findMany: vi.fn() }, - $transaction: vi.fn(), - }, + $transaction: vi.fn() + } })); -// Producto tal como lo devuelve Prisma (antes del mapeo) +const TEST_JWT_SECRET = "test-secret-products"; +let sellerToken; + +beforeAll(() => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + sellerToken = jwt.sign( + { id_user: 1, email: "seller@test.com", role: "SELLER" }, + TEST_JWT_SECRET + ); +}); + const mockDbProduct = { id_product: 1, name: "Producto Test", description: "Descripcion de prueba", - price: 25.0, + price: 25, + offer_price: null, fk_product_category: 1, fk_store: 1, visible: true, + is_offer: false, created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:00.000Z", - product_category: { id_product_category: 1, name: "Categoria Test", status: true }, + product_category: { + id_product_category: 1, + name: "Categoria Test", + status: true + }, product_tag_relations: [], + product_reviews: [] }; -// Producto tal como lo devuelve el endpoint de lista (select parcial, sin mapeo) const mockListProduct = { id_product: 1, name: "Producto Test", description: "Descripcion de prueba", - price: 25.0, - store: { id_store: 1, name: "Tienda Test" }, + price: 25, + offer_price: null, + is_offer: false, + store: { id_store: 1, name: "Tienda Test" } }; -// ─── GET /products ──────────────────────────────────────────────────────────── - describe("GET /products", () => { beforeEach(() => vi.clearAllMocks()); - it("devuelve 200 con lista vacía y estructura de paginación", async () => { + it("devuelve 200 con lista vacia y estructura de paginacion", async () => { prisma.products.count.mockResolvedValue(0); prisma.products.findMany.mockResolvedValue([]); @@ -68,11 +84,11 @@ describe("GET /products", () => { totalProducts: 0, page: 1, limit: 20, - totalPages: 0, + totalPages: 0 }); }); - it("devuelve productos con búsqueda por nombre o descripción", async () => { + it("devuelve productos con busqueda por nombre o descripcion", async () => { prisma.products.count.mockResolvedValue(1); prisma.products.findMany.mockResolvedValue([mockListProduct]); @@ -80,10 +96,46 @@ describe("GET /products", () => { expect(res.status).toBe(200); expect(res.body.products).toHaveLength(1); + expect(res.body.products[0]).toMatchObject({ + price: 25, + original_price: 25, + offer_price: null, + is_offer: false + }); expect(res.body.pagination.totalProducts).toBe(1); }); - it("respeta page y limit en la paginación", async () => { + it("permite filtrar productos en oferta con isOffer=true", async () => { + prisma.products.count.mockResolvedValue(1); + prisma.products.findMany.mockResolvedValue([ + { + ...mockListProduct, + price: 25, + offer_price: 19.9, + is_offer: true + } + ]); + + const res = await request(app).get("/products?isOffer=true"); + + expect(res.status).toBe(200); + expect(res.body.products).toHaveLength(1); + expect(res.body.products[0]).toMatchObject({ + price: 19.9, + original_price: 25, + offer_price: 19.9, + is_offer: true + }); + expect(prisma.products.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + is_offer: true + }) + }) + ); + }); + + it("respeta page y limit en la paginacion", async () => { prisma.products.count.mockResolvedValue(100); prisma.products.findMany.mockResolvedValue([]); @@ -94,11 +146,11 @@ describe("GET /products", () => { page: 3, limit: 10, totalProducts: 100, - totalPages: 10, + totalPages: 10 }); }); - it("usa page=1 y limit=20 cuando los parámetros son inválidos", async () => { + it("usa page=1 y limit=20 cuando los parametros son invalidos", async () => { prisma.products.count.mockResolvedValue(0); prisma.products.findMany.mockResolvedValue([]); @@ -109,8 +161,6 @@ describe("GET /products", () => { }); }); -// ─── GET /products/:id ──────────────────────────────────────────────────────── - describe("GET /products/:id", () => { beforeEach(() => vi.clearAllMocks()); @@ -123,11 +173,34 @@ describe("GET /products/:id", () => { expect(res.body).toMatchObject({ id: 1, name: "Producto Test", - status: "active", + price: 25, + originalPrice: 25, + offerPrice: null, + isOffer: false, + status: "active" }); expect(res.body).toHaveProperty("category"); }); + it("devuelve el precio de oferta como price cuando el producto esta en oferta", async () => { + prisma.products.findFirst.mockResolvedValue({ + ...mockDbProduct, + price: 25, + offer_price: 17.5, + is_offer: true + }); + + const res = await request(app).get("/products/1"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + price: 17.5, + originalPrice: 25, + offerPrice: 17.5, + isOffer: true + }); + }); + it("devuelve status 'pending' cuando el producto tiene visible=false", async () => { prisma.products.findFirst.mockResolvedValue({ ...mockDbProduct, visible: false }); @@ -137,11 +210,11 @@ describe("GET /products/:id", () => { expect(res.body.status).toBe("pending"); }); - it("devuelve 400 cuando el id no es numérico", async () => { + it("devuelve 400 cuando el id no es numerico", async () => { const res = await request(app).get("/products/abc"); expect(res.status).toBe(400); - expect(res.body.message).toBe("ID de producto inválido"); + expect(res.body.message).toBe("ID de producto invalido"); }); it("devuelve 400 cuando el id es 0", async () => { @@ -160,24 +233,34 @@ describe("GET /products/:id", () => { }); }); -// ─── POST /products ─────────────────────────────────────────────────────────── - describe("POST /products", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + prisma.users.findUnique.mockResolvedValue({ + id_user: 1, + role: "SELLER", + status: true, + store: { id_store: 1, status: true } + }); + prisma.productCategories.findUnique.mockResolvedValue({ + id_product_category: 1, + status: true + }); + }); - it("devuelve 401 cuando falta el header x-user-id", async () => { + it("devuelve 401 cuando falta autenticacion", async () => { const res = await request(app) .post("/products") .send({ name: "Test", price: 10, categoryId: 1 }); expect(res.status).toBe(401); - expect(res.body.message).toMatch(/autenticado/i); + expect(JSON.stringify(res.body)).toMatch(/autenticado/i); }); - it("devuelve 400 cuando name está vacío", async () => { + it("devuelve 400 cuando name esta vacio", async () => { const res = await request(app) .post("/products") - .set("x-user-id", "1") + .set("Cookie", `userToken=${sellerToken}`) .send({ name: "", price: 10, categoryId: 1 }); expect(res.status).toBe(400); @@ -187,7 +270,7 @@ describe("POST /products", () => { it("devuelve 400 cuando falta price", async () => { const res = await request(app) .post("/products") - .set("x-user-id", "1") + .set("Cookie", `userToken=${sellerToken}`) .send({ name: "Test", categoryId: 1 }); expect(res.status).toBe(400); @@ -197,7 +280,7 @@ describe("POST /products", () => { it("devuelve 400 cuando price es 0 o negativo", async () => { const res = await request(app) .post("/products") - .set("x-user-id", "1") + .set("Cookie", `userToken=${sellerToken}`) .send({ name: "Test", price: -5, categoryId: 1 }); expect(res.status).toBe(400); @@ -207,17 +290,17 @@ describe("POST /products", () => { it("devuelve 400 cuando falta categoryId", async () => { const res = await request(app) .post("/products") - .set("x-user-id", "1") + .set("Cookie", `userToken=${sellerToken}`) .send({ name: "Test", price: 10 }); expect(res.status).toBe(400); expect(res.body.message).toMatch(/categoryId/i); }); - it("devuelve 400 cuando visible tiene valor inválido", async () => { + it("devuelve 400 cuando visible tiene valor invalido", async () => { const res = await request(app) .post("/products") - .set("x-user-id", "1") + .set("Cookie", `userToken=${sellerToken}`) .send({ name: "Test", price: 10, categoryId: 1, visible: "invalido" }); expect(res.status).toBe(400); @@ -227,44 +310,95 @@ describe("POST /products", () => { it("devuelve 400 cuando tags no es un array", async () => { const res = await request(app) .post("/products") - .set("x-user-id", "1") + .set("Cookie", `userToken=${sellerToken}`) .send({ name: "Test", price: 10, categoryId: 1, tags: "no-array" }); expect(res.status).toBe(400); expect(res.body.message).toMatch(/tags/i); }); - it("devuelve 201 con el producto creado cuando los datos son válidos", async () => { + it("devuelve 201 con el producto creado cuando los datos son validos", async () => { prisma.users.findUnique.mockResolvedValue({ id_user: 1, role: "SELLER", status: true, - store: { id_store: 1, status: true }, + store: { id_store: 1, status: true } }); prisma.productCategories.findUnique.mockResolvedValue({ id_product_category: 1, - status: true, + status: true }); prisma.$transaction.mockImplementation(async (fn) => fn({ products: { create: vi.fn().mockResolvedValue({ id_product: 1 }), - findUnique: vi.fn().mockResolvedValue(mockDbProduct), + findFirst: vi.fn().mockResolvedValue(mockDbProduct) }, productTagRelations: { - createMany: vi.fn().mockResolvedValue([]), - }, + createMany: vi.fn().mockResolvedValue([]) + } }) ); const res = await request(app) .post("/products") - .set("x-user-id", "1") + .set("Cookie", `userToken=${sellerToken}`) .send({ name: "Producto Test", price: 25, categoryId: 1 }); expect(res.status).toBe(201); expect(res.body).toHaveProperty("id"); expect(res.body.name).toBe("Producto Test"); expect(res.body.status).toBe("active"); + expect(res.body.price).toBe(25); + }); + + it("crea un producto en oferta y devuelve el precio efectivo sin perder el original", async () => { + prisma.users.findUnique.mockResolvedValue({ + id_user: 1, + role: "SELLER", + status: true, + store: { id_store: 1, status: true } + }); + prisma.productCategories.findUnique.mockResolvedValue({ + id_product_category: 1, + status: true + }); + prisma.$transaction.mockImplementation(async (fn) => + fn({ + products: { + create: vi.fn().mockResolvedValue({ id_product: 1 }), + findFirst: vi.fn().mockResolvedValue({ + ...mockDbProduct, + name: "Producto Oferta", + price: 30, + offer_price: 22.5, + is_offer: true + }) + }, + productTagRelations: { + createMany: vi.fn().mockResolvedValue([]) + } + }) + ); + + const res = await request(app) + .post("/products") + .set("Cookie", `userToken=${sellerToken}`) + .send({ + name: "Producto Oferta", + price: 30, + offerPrice: 22.5, + isOffer: true, + categoryId: 1 + }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ + name: "Producto Oferta", + price: 22.5, + originalPrice: 30, + offerPrice: 22.5, + isOffer: true + }); }); }); diff --git a/tests/e2e/stores.test.js b/tests/e2e/stores.test.js index 2ef6650..8e02527 100644 --- a/tests/e2e/stores.test.js +++ b/tests/e2e/stores.test.js @@ -19,6 +19,7 @@ vi.mock("../../src/lib/prisma.js", () => ({ findUnique: vi.fn(), }, products: { + count: vi.fn(), findMany: vi.fn(), updateMany: vi.fn(), }, @@ -82,7 +83,7 @@ const mockProducts = [ // ─── GET /api/commerces/:id ─────────────────────────────────────────────────── describe("GET /api/commerces/:id", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => vi.resetAllMocks()); it("devuelve 200 con datos del comercio cuando existe", async () => { prisma.stores.findUnique.mockResolvedValue(mockStore); @@ -98,7 +99,7 @@ describe("GET /api/commerces/:id", () => { const res = await request(app).get("/api/commerces/abc"); expect(res.status).toBe(400); - expect(res.body.message).toMatch(/numero/i); + expect(res.body.message).toMatch(/n.mero/i); }); it("devuelve 404 cuando el comercio no existe", async () => { @@ -114,7 +115,7 @@ describe("GET /api/commerces/:id", () => { // ─── GET /api/commerces/products/:id ───────────────────────────────────────── describe("GET /api/commerces/products/:id", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => vi.resetAllMocks()); it("devuelve 200 con los productos del comercio", async () => { // Primera llamada: verificar que el store existe @@ -150,10 +151,11 @@ describe("GET /api/commerces/products/:id", () => { // ─── GET /api/commerces/products/filter/:id ─────────────────────────────────── describe("GET /api/commerces/products/filter/:id", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => vi.resetAllMocks()); it("devuelve 200 con productos filtrados por precio", async () => { prisma.stores.findUnique.mockResolvedValue({ id_store: 1 }); + prisma.products.count.mockResolvedValue(1); prisma.products.findMany.mockResolvedValue(mockProducts); const res = await request(app).get( @@ -161,11 +163,13 @@ describe("GET /api/commerces/products/filter/:id", () => { ); expect(res.status).toBe(200); - expect(Array.isArray(res.body)).toBe(true); + expect(res.body).toHaveProperty("content"); + expect(Array.isArray(res.body.content)).toBe(true); }); it("devuelve 404 cuando no hay productos con los filtros aplicados", async () => { prisma.stores.findUnique.mockResolvedValue({ id_store: 1 }); + prisma.products.count.mockResolvedValue(0); prisma.products.findMany.mockResolvedValue([]); const res = await request(app).get( @@ -179,7 +183,7 @@ describe("GET /api/commerces/products/filter/:id", () => { // ─── POST /api/commerces ────────────────────────────────────────────────────── describe("POST /api/commerces", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => vi.resetAllMocks()); it("devuelve 401 cuando no hay cookie de autenticación", async () => { const res = await request(app).post("/api/commerces").send({}); @@ -277,7 +281,7 @@ describe("POST /api/commerces", () => { // ─── PUT /api/commerces/:id ─────────────────────────────────────────────────── describe("PUT /api/commerces/:id", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => vi.resetAllMocks()); it("devuelve 401 cuando no hay cookie de autenticación", async () => { const res = await request(app) @@ -342,7 +346,7 @@ describe("PUT /api/commerces/:id", () => { // ─── DELETE /api/commerces/:id ──────────────────────────────────────────────── describe("DELETE /api/commerces/:id", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => vi.resetAllMocks()); it("devuelve 401 cuando no hay cookie de sesión", async () => { const res = await request(app).delete("/api/commerces/1");