diff --git a/prisma/migrations/20260521004958_add_banners/migration.sql b/prisma/migrations/20260521004958_add_banners/migration.sql new file mode 100644 index 00000000..3fcbce73 --- /dev/null +++ b/prisma/migrations/20260521004958_add_banners/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Banners" ( + "id_banner" SERIAL NOT NULL, + "title" VARCHAR(120) NOT NULL, + "description" TEXT, + "image_url" VARCHAR(500) NOT NULL, + "link_url" VARCHAR(500), + "start_at" TIMESTAMPTZ NOT NULL, + "end_at" TIMESTAMPTZ, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "status" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL, + + CONSTRAINT "Banners_pkey" PRIMARY KEY ("id_banner") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 235305e1..b5cd7762 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,21 @@ model Notifications { user Users @relation(fields: [fk_user], references: [id_user]) } +//------------------------------------BANNERS PROMOCIONALES---------------------------------------- +model Banners { + id_banner Int @id @default(autoincrement()) + title String @db.VarChar(120) + description String? @db.Text + image_url String @db.VarChar(500) + link_url String? @db.VarChar(500) + start_at DateTime @db.Timestamptz + end_at DateTime? @db.Timestamptz + is_active Boolean @default(true) + status Boolean @default(true) + created_at DateTime @default(now()) @db.Timestamptz + updated_at DateTime @updatedAt @db.Timestamptz +} + //------------------------------------COMERCIOS---------------------------------------- model Stores { id_store Int @id @default(autoincrement()) diff --git a/src/app.js b/src/app.js index 426a4f09..5e7d9fee 100644 --- a/src/app.js +++ b/src/app.js @@ -55,7 +55,9 @@ import { adminCategoryRoutes, adminStoresRoutes, adminProductsRoutes, + adminBannersRoutes, } from "./modules/admin/index.js"; +import { bannerRoutes } from "./modules/global/banners/banners.routes.js"; const app = express(); @@ -131,10 +133,14 @@ app.use("/api/admin", adminUsersRoutes); app.use("/api/admin/categories", adminCategoryRoutes); app.use("/api/admin/stores", adminStoresRoutes); app.use("/api/admin/products", adminProductsRoutes); +app.use("/api/admin/banners", adminBannersRoutes); // Rutas de distancias app.use("/api/distances", distanceRoutes); +// Rutas de banners +app.use("/api/banners", bannerRoutes); + app.use('/products', productImageRoutes) app.use('/users', userImageRoutes) app.use('/stores', storeImageRoutes) diff --git a/src/config/swagger.config.js b/src/config/swagger.config.js index 32735ffe..c52e3c5e 100644 --- a/src/config/swagger.config.js +++ b/src/config/swagger.config.js @@ -15,10 +15,12 @@ import { categoryRequestSchemas, deliverySchemas, orderSchemas, + bannerSchemas, // /session sessionSchemas, // /admin adminCategorySchemas, + adminBannerSchemas, } from "../docs/schemas/index.js"; const options = { @@ -54,6 +56,7 @@ const options = { { name: "Deliveries", description: "Gestión de repartidores" }, { name: "Images", description: "Gestión de imágenes" }, { name: "Admin", description: "Gestión de administración" }, + { name: "Banners", description: "Banners promocionales" }, ], components: { securitySchemes: { @@ -77,12 +80,14 @@ const options = { ...categoryRequestSchemas, ...deliverySchemas, ...orderSchemas, + ...bannerSchemas, // /session ...sessionSchemas, // /images ...imageSchemas, // /admin ...adminCategorySchemas, + ...adminBannerSchemas, } }, security: [ diff --git a/src/docs/schemas/admin/admin-banner.schema.js b/src/docs/schemas/admin/admin-banner.schema.js new file mode 100644 index 00000000..ce1e3230 --- /dev/null +++ b/src/docs/schemas/admin/admin-banner.schema.js @@ -0,0 +1,68 @@ +export const adminBannerSchemas = { + AdminBannerCreateBody: { + type: "object", + required: ["title", "imageUrl", "startAt"], + properties: { + title: { type: "string", minLength: 1, maxLength: 120, example: "Semana Eco" }, + description: { type: "string", nullable: true, example: "Descuentos hasta 30%" }, + imageUrl: { type: "string", example: "https://cdn.example.com/banners/eco.jpg" }, + linkUrl: { type: "string", nullable: true, example: "https://frontend.example.com/ofertas" }, + startAt: { type: "string", format: "date-time" }, + endAt: { type: "string", format: "date-time", nullable: true }, + isActive: { type: "boolean", example: true } + } + }, + AdminBannerUpdateBody: { + type: "object", + properties: { + title: { type: "string", minLength: 1, maxLength: 120, example: "Semana Eco" }, + description: { type: "string", nullable: true, example: "Descuentos hasta 30%" }, + imageUrl: { type: "string", example: "https://cdn.example.com/banners/eco.jpg" }, + linkUrl: { type: "string", nullable: true, example: "https://frontend.example.com/ofertas" }, + startAt: { type: "string", format: "date-time" }, + endAt: { type: "string", format: "date-time", nullable: true }, + isActive: { type: "boolean", example: true } + } + }, + AdminBannerToggleBody: { + type: "object", + required: ["isActive"], + properties: { + isActive: { type: "boolean", example: false } + } + }, + AdminBannerResponse: { + type: "object", + properties: { + id: { type: "integer", example: 12 }, + title: { type: "string", example: "Semana Eco" }, + description: { type: "string", nullable: true, example: "Descuentos hasta 30%" }, + imageUrl: { type: "string", example: "https://cdn.example.com/banners/eco.jpg" }, + linkUrl: { type: "string", nullable: true, example: "https://frontend.example.com/ofertas" }, + startAt: { type: "string", format: "date-time" }, + endAt: { type: "string", format: "date-time", nullable: true }, + isActive: { type: "boolean", example: true }, + status: { type: "boolean", example: true }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" } + } + }, + AdminBannerListResponse: { + type: "object", + properties: { + data: { + type: "array", + items: { $ref: "#/components/schemas/AdminBannerResponse" } + }, + pagination: { + type: "object", + properties: { + total: { type: "integer", example: 3 }, + page: { type: "integer", example: 1 }, + limit: { type: "integer", example: 20 }, + totalPages: { type: "integer", example: 1 } + } + } + } + } +}; diff --git a/src/docs/schemas/banner.schema.js b/src/docs/schemas/banner.schema.js new file mode 100644 index 00000000..8ca2b6a3 --- /dev/null +++ b/src/docs/schemas/banner.schema.js @@ -0,0 +1,20 @@ +export const bannerSchemas = { + BannerPublicResponse: { + type: "object", + properties: { + id: { type: "integer", example: 12 }, + title: { type: "string", example: "Semana Eco" }, + description: { type: "string", nullable: true, example: "Descuentos hasta 30%" }, + imageUrl: { type: "string", example: "https://cdn.example.com/banners/eco.jpg" }, + linkUrl: { type: "string", nullable: true, example: "https://frontend.example.com/ofertas" }, + startAt: { type: "string", format: "date-time" }, + endAt: { type: "string", format: "date-time", nullable: true }, + isActive: { type: "boolean", example: true }, + createdAt: { type: "string", format: "date-time" } + } + }, + BannerPublicListResponse: { + type: "array", + items: { $ref: "#/components/schemas/BannerPublicResponse" } + } +}; diff --git a/src/docs/schemas/index.js b/src/docs/schemas/index.js index cec8af58..259dd352 100644 --- a/src/docs/schemas/index.js +++ b/src/docs/schemas/index.js @@ -15,12 +15,14 @@ export * from "./commerce/category-request.schema.js"; export * from "./commerce/delivery-assignment.schema.js"; export * from "./delivery.schema.js"; export * from "./order.schema.js"; +export * from "./banner.schema.js"; // Session schemas export * from "./session/session.schema.js"; // Admin schemas export * from "./admin/admin-category.schema.js"; +export * from "./admin/admin-banner.schema.js"; // Users schemas export * from "./users/cart.schema.js" \ No newline at end of file diff --git a/src/modules/admin/banners/admin-banners.controller.js b/src/modules/admin/banners/admin-banners.controller.js new file mode 100644 index 00000000..0eeb977a --- /dev/null +++ b/src/modules/admin/banners/admin-banners.controller.js @@ -0,0 +1,46 @@ +import { validateId } from "../../../lib/validators.js"; +import { + createAdminBannerService, + getAdminBannersService, + toggleAdminBannerActiveService, + updateAdminBannerService, +} from "./admin-banners.service.js"; + +export const createAdminBanner = async (req, res, next) => { + try { + const result = await createAdminBannerService(req.body); + return res.status(201).json(result); + } catch (error) { + next(error); + } +}; + +export const getAdminBanners = async (req, res, next) => { + try { + const { search, active, status } = req.query; + const result = await getAdminBannersService({ search, active, status }, req.pagination); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const updateAdminBanner = async (req, res, next) => { + try { + const id = validateId(req.params.id); + const result = await updateAdminBannerService(id, req.body); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const toggleAdminBannerActive = async (req, res, next) => { + try { + const id = validateId(req.params.id); + const result = await toggleAdminBannerActiveService(id, req.body?.isActive); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/admin/banners/admin-banners.routes.js b/src/modules/admin/banners/admin-banners.routes.js new file mode 100644 index 00000000..5cd96830 --- /dev/null +++ b/src/modules/admin/banners/admin-banners.routes.js @@ -0,0 +1,166 @@ +import { Router } from "express"; +import authenticate from "../../../config/jwt.config.js"; +import { requireRole } from "../../../middlewares/auth.middleware.js"; +import { parsePagination } from "../../../middlewares/pagination.middleware.js"; +import { ROLES } from "../../../constants/roles.constant.js"; +import { + createAdminBanner, + getAdminBanners, + toggleAdminBannerActive, + updateAdminBanner, +} from "./admin-banners.controller.js"; + +const router = Router(); + +/** + * @swagger + * /api/admin/banners: + * post: + * summary: Crear banner promocional (Admin) + * tags: [Admin] + * security: + * - cookieAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdminBannerCreateBody' + * responses: + * 201: + * description: Banner creado correctamente + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdminBannerResponse' + * 400: + * description: Datos inválidos + * 401: + * description: No autenticado + * 403: + * description: Sin permisos de administrador + */ +router.post("/", authenticate, requireRole(ROLES.ADMIN), createAdminBanner); + +/** + * @swagger + * /api/admin/banners: + * get: + * summary: Listar banners promocionales (Admin) + * tags: [Admin] + * security: + * - cookieAuth: [] + * parameters: + * - in: query + * name: search + * schema: + * type: string + * - in: query + * name: active + * schema: + * type: string + * enum: [true, false] + * - in: query + * name: status + * schema: + * type: string + * enum: [true, false] + * - in: query + * name: page + * schema: + * type: integer + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Lista de banners + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdminBannerListResponse' + * 401: + * description: No autenticado + * 403: + * description: Sin permisos de administrador + */ +router.get("/", authenticate, requireRole(ROLES.ADMIN), parsePagination, getAdminBanners); + +/** + * @swagger + * /api/admin/banners/{id}: + * put: + * summary: Actualizar banner promocional (Admin) + * tags: [Admin] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdminBannerUpdateBody' + * responses: + * 200: + * description: Banner actualizado + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdminBannerResponse' + * 400: + * description: Datos inválidos + * 401: + * description: No autenticado + * 403: + * description: Sin permisos de administrador + * 404: + * description: Banner no encontrado + */ +router.put("/:id", authenticate, requireRole(ROLES.ADMIN), updateAdminBanner); + +/** + * @swagger + * /api/admin/banners/{id}/active: + * patch: + * summary: Activar o desactivar banner (Admin) + * tags: [Admin] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdminBannerToggleBody' + * responses: + * 200: + * description: Banner actualizado + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdminBannerResponse' + * 400: + * description: Datos inválidos + * 401: + * description: No autenticado + * 403: + * description: Sin permisos de administrador + * 404: + * description: Banner no encontrado + */ +router.patch("/:id/active", authenticate, requireRole(ROLES.ADMIN), toggleAdminBannerActive); + +export { router as adminBannersRoutes }; diff --git a/src/modules/admin/banners/admin-banners.service.js b/src/modules/admin/banners/admin-banners.service.js new file mode 100644 index 00000000..a91291ca --- /dev/null +++ b/src/modules/admin/banners/admin-banners.service.js @@ -0,0 +1,261 @@ +import { prisma } from "../../../lib/prisma.js"; +import { NotFoundError, ValidationError } from "../../../lib/errors.js"; +import { validateId } from "../../../lib/validators.js"; + +const TITLE_MAX = 120; +const URL_MAX = 500; + +const parseBoolean = (value, fieldName) => { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + } + throw new ValidationError(`${fieldName} debe ser boolean`); +}; + +const normalizeText = (value, fieldName, { required = false, maxLength } = {}) => { + if (value === undefined || value === null) { + if (required) throw new ValidationError(`${fieldName} es requerido`); + return null; + } + + if (typeof value !== "string") { + throw new ValidationError(`${fieldName} debe ser string`); + } + + const trimmed = value.trim(); + if (required && trimmed.length === 0) { + throw new ValidationError(`${fieldName} no puede estar vacío`); + } + + if (maxLength && trimmed.length > maxLength) { + throw new ValidationError(`${fieldName} no puede superar ${maxLength} caracteres`); + } + + return trimmed.length === 0 ? null : trimmed; +}; + +const parseDate = (value, fieldName, { required = false } = {}) => { + if (value === undefined || value === null || value === "") { + if (required) throw new ValidationError(`${fieldName} es requerido`); + return null; + } + + const date = value instanceof Date ? value : new Date(value); + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + throw new ValidationError(`${fieldName} debe ser una fecha válida`); + } + + return date; +}; + +const bannerSelect = { + id_banner: true, + title: true, + description: true, + image_url: true, + link_url: true, + start_at: true, + end_at: true, + is_active: true, + status: true, + created_at: true, + updated_at: true, +}; + +const mapBannerResponse = (banner) => ({ + id: banner.id_banner, + title: banner.title, + description: banner.description ?? null, + imageUrl: banner.image_url, + linkUrl: banner.link_url ?? null, + startAt: banner.start_at, + endAt: banner.end_at ?? null, + isActive: banner.is_active, + status: banner.status, + createdAt: banner.created_at, + updatedAt: banner.updated_at, +}); + +export const createAdminBannerService = async (payload = {}) => { + const title = normalizeText(payload.title, "title", { required: true, maxLength: TITLE_MAX }); + const description = normalizeText(payload.description, "description", { maxLength: 500 }); + const imageUrl = normalizeText(payload.imageUrl, "imageUrl", { required: true, maxLength: URL_MAX }); + const linkUrl = normalizeText(payload.linkUrl, "linkUrl", { maxLength: URL_MAX }); + const startAt = parseDate(payload.startAt, "startAt", { required: true }); + const endAt = parseDate(payload.endAt, "endAt"); + const isActive = payload.isActive === undefined + ? true + : parseBoolean(payload.isActive, "isActive"); + + if (endAt && startAt && endAt < startAt) { + throw new ValidationError("endAt no puede ser anterior a startAt"); + } + + const created = await prisma.banners.create({ + data: { + title, + description, + image_url: imageUrl, + link_url: linkUrl, + start_at: startAt, + end_at: endAt, + is_active: isActive, + status: true, + }, + select: bannerSelect, + }); + + return mapBannerResponse(created); +}; + +export const getAdminBannersService = async (filters = {}, pagination = {}) => { + const where = {}; + + if (filters.status === "true") where.status = true; + else if (filters.status === "false") where.status = false; + + if (filters.active !== undefined) { + const active = parseBoolean(filters.active, "active"); + where.is_active = active; + } + + if (filters.search?.toString().trim()) { + const term = filters.search.toString().trim(); + where.OR = [ + { title: { contains: term, mode: "insensitive" } }, + { description: { contains: term, mode: "insensitive" } }, + ]; + } + + const { page = 1, limit = 20, skip = 0 } = pagination; + const safeLimit = Number(limit) > 0 ? Number(limit) : 20; + const safeSkip = Number(skip) >= 0 ? Number(skip) : 0; + + const [total, banners] = await Promise.all([ + prisma.banners.count({ where }), + prisma.banners.findMany({ + where, + skip: safeSkip, + take: safeLimit, + orderBy: [{ start_at: "desc" }, { id_banner: "desc" }], + select: bannerSelect, + }), + ]); + + return { + data: banners.map(mapBannerResponse), + pagination: { + total, + page, + limit: safeLimit, + totalPages: Math.ceil(total / safeLimit), + }, + }; +}; + +export const updateAdminBannerService = async (bannerId, payload = {}) => { + const id = validateId(bannerId); + + const existing = await prisma.banners.findUnique({ + where: { id_banner: id }, + select: bannerSelect, + }); + + if (!existing || existing.status === false) { + throw new NotFoundError("Banner no encontrado"); + } + + const hasStartAt = Object.prototype.hasOwnProperty.call(payload, "startAt"); + const hasEndAt = Object.prototype.hasOwnProperty.call(payload, "endAt"); + + const startAt = hasStartAt + ? parseDate(payload.startAt, "startAt", { required: true }) + : existing.start_at; + + let endAt = existing.end_at; + if (hasEndAt) { + if (payload.endAt === null || payload.endAt === "") { + endAt = null; + } else { + endAt = parseDate(payload.endAt, "endAt"); + } + } + + if (endAt && startAt && endAt < startAt) { + throw new ValidationError("endAt no puede ser anterior a startAt"); + } + + const data = {}; + + if (payload.title !== undefined) { + data.title = normalizeText(payload.title, "title", { required: true, maxLength: TITLE_MAX }); + } + + if (payload.description !== undefined) { + data.description = normalizeText(payload.description, "description", { maxLength: 500 }); + } + + if (payload.imageUrl !== undefined) { + data.image_url = normalizeText(payload.imageUrl, "imageUrl", { required: true, maxLength: URL_MAX }); + } + + if (payload.linkUrl !== undefined) { + data.link_url = normalizeText(payload.linkUrl, "linkUrl", { maxLength: URL_MAX }); + } + + if (hasStartAt) data.start_at = startAt; + if (hasEndAt) data.end_at = endAt; + + if (payload.isActive !== undefined) { + data.is_active = parseBoolean(payload.isActive, "isActive"); + } + + const { count } = await prisma.banners.updateMany({ + where: { id_banner: id, status: true }, + data, + }); + + if (count === 0) { + throw new NotFoundError("Banner no encontrado"); + } + + const updated = await prisma.banners.findUnique({ + where: { id_banner: id }, + select: bannerSelect, + }); + + return mapBannerResponse(updated); +}; + +export const toggleAdminBannerActiveService = async (bannerId, isActive) => { + const id = validateId(bannerId); + const normalizedActive = parseBoolean(isActive, "isActive"); + + const existing = await prisma.banners.findUnique({ + where: { id_banner: id }, + select: { id_banner: true, status: true }, + }); + + if (!existing || existing.status === false) { + throw new NotFoundError("Banner no encontrado"); + } + + const { count } = await prisma.banners.updateMany({ + where: { id_banner: id, status: true }, + data: { is_active: normalizedActive }, + }); + + if (count === 0) { + throw new NotFoundError("Banner no encontrado"); + } + + const updated = await prisma.banners.findUnique({ + where: { id_banner: id }, + select: bannerSelect, + }); + + return mapBannerResponse(updated); +}; diff --git a/src/modules/admin/index.js b/src/modules/admin/index.js index 5d7039fd..e107da65 100644 --- a/src/modules/admin/index.js +++ b/src/modules/admin/index.js @@ -1,4 +1,5 @@ export * from "./categories/admin-category.routes.js"; export * from "./users/admin-users.routes.js"; export * from "./stores/admin-stores.routes.js"; -export * from "./products/admin-products.routes.js"; \ No newline at end of file +export * from "./products/admin-products.routes.js"; +export * from "./banners/admin-banners.routes.js"; \ No newline at end of file diff --git a/src/modules/global/banners/banners.controller.js b/src/modules/global/banners/banners.controller.js new file mode 100644 index 00000000..53b01f37 --- /dev/null +++ b/src/modules/global/banners/banners.controller.js @@ -0,0 +1,11 @@ +import { getActiveBannersService } from "./banners.service.js"; + +export const getActiveBanners = async (req, res, next) => { + try { + const { limit } = req.query; + const result = await getActiveBannersService({ limit }); + return res.status(200).json(result); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/global/banners/banners.routes.js b/src/modules/global/banners/banners.routes.js new file mode 100644 index 00000000..4bde44ad --- /dev/null +++ b/src/modules/global/banners/banners.routes.js @@ -0,0 +1,27 @@ +import { Router } from "express"; +import { getActiveBanners } from "./banners.controller.js"; + +const router = Router(); + +/** + * @swagger + * /api/banners: + * get: + * summary: Obtener banners activos para el homepage + * tags: [Banners] + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * responses: + * 200: + * description: Lista de banners activos + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BannerPublicListResponse' + */ +router.get("/", getActiveBanners); + +export { router as bannerRoutes }; diff --git a/src/modules/global/banners/banners.service.js b/src/modules/global/banners/banners.service.js new file mode 100644 index 00000000..ef0f2cdd --- /dev/null +++ b/src/modules/global/banners/banners.service.js @@ -0,0 +1,54 @@ +import { prisma } from "../../../lib/prisma.js"; +import { ValidationError } from "../../../lib/errors.js"; + +const bannerSelect = { + id_banner: true, + title: true, + description: true, + image_url: true, + link_url: true, + start_at: true, + end_at: true, + is_active: true, + created_at: true, +}; + +const mapBannerResponse = (banner) => ({ + id: banner.id_banner, + title: banner.title, + description: banner.description ?? null, + imageUrl: banner.image_url, + linkUrl: banner.link_url ?? null, + startAt: banner.start_at, + endAt: banner.end_at ?? null, + isActive: banner.is_active, + createdAt: banner.created_at, +}); + +export const getActiveBannersService = async (filters = {}) => { + const limitRaw = filters.limit !== undefined ? Number(filters.limit) : 10; + if (!Number.isInteger(limitRaw) || limitRaw <= 0) { + throw new ValidationError("limit debe ser un entero positivo"); + } + const limit = Math.min(limitRaw, 50); + + if (!prisma?.banners?.findMany) { + throw new Error("No se pudo obtener banners de la BD, verificar migraciones y prisma generate"); + } + + const now = new Date(); + + const banners = await prisma.banners.findMany({ + where: { + status: true, + is_active: true, + start_at: { lte: now }, + OR: [{ end_at: null }, { end_at: { gte: now } }], + }, + orderBy: [{ start_at: "desc" }, { id_banner: "desc" }], + take: limit, + select: bannerSelect, + }); + + return banners.map(mapBannerResponse); +}; diff --git a/tests/unit/admin/admin-banners.test.js b/tests/unit/admin/admin-banners.test.js new file mode 100644 index 00000000..49fd6642 --- /dev/null +++ b/tests/unit/admin/admin-banners.test.js @@ -0,0 +1,154 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { prisma } from "../../../src/lib/prisma.js"; +import { + createAdminBannerService, + getAdminBannersService, + toggleAdminBannerActiveService, + updateAdminBannerService, +} from "../../../src/modules/admin/banners/admin-banners.service.js"; +import { NotFoundError, ValidationError } from "../../../src/lib/errors.js"; + +vi.mock("../../../src/lib/prisma.js", () => ({ + prisma: { + banners: { + create: vi.fn(), + count: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +const mockBanner = { + id_banner: 1, + title: "Semana Eco", + description: "Descuentos hasta 30%", + image_url: "https://cdn.example.com/banner.jpg", + link_url: "https://frontend.example.com/ofertas", + start_at: new Date("2024-05-01T10:00:00Z"), + end_at: new Date("2024-05-10T10:00:00Z"), + is_active: true, + status: true, + created_at: new Date("2024-04-30T10:00:00Z"), + updated_at: new Date("2024-04-30T10:00:00Z"), +}; + +const pagination = { page: 1, limit: 10, skip: 0 }; + +describe("createAdminBannerService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza ValidationError cuando title está vacío", async () => { + await expect( + createAdminBannerService({ imageUrl: "https://img", startAt: "2024-05-01" }) + ).rejects.toThrow(ValidationError); + }); + + it("lanza ValidationError cuando endAt es anterior a startAt", async () => { + await expect( + createAdminBannerService({ + title: "Campaña", + imageUrl: "https://img", + startAt: "2024-06-10", + endAt: "2024-06-01", + }) + ).rejects.toThrow(ValidationError); + }); + + it("crea el banner con los campos normalizados", async () => { + prisma.banners.create.mockResolvedValue(mockBanner); + + const result = await createAdminBannerService({ + title: " Semana Eco ", + description: " Descuentos ", + imageUrl: "https://cdn.example.com/banner.jpg", + linkUrl: "https://frontend.example.com/ofertas", + startAt: "2024-05-01T10:00:00Z", + endAt: "2024-05-10T10:00:00Z", + isActive: true, + }); + + expect(prisma.banners.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + title: "Semana Eco", + description: "Descuentos", + image_url: "https://cdn.example.com/banner.jpg", + link_url: "https://frontend.example.com/ofertas", + }) + }) + ); + + expect(result.title).toBe("Semana Eco"); + expect(result.imageUrl).toBe("https://cdn.example.com/banner.jpg"); + }); +}); + +describe("getAdminBannersService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("aplica filtro active cuando se solicita", async () => { + prisma.banners.count.mockResolvedValue(0); + prisma.banners.findMany.mockResolvedValue([]); + + await getAdminBannersService({ active: "true" }, pagination); + + expect(prisma.banners.count).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ is_active: true }) }) + ); + }); + + it("aplica search por titulo y descripcion", async () => { + prisma.banners.count.mockResolvedValue(0); + prisma.banners.findMany.mockResolvedValue([]); + + await getAdminBannersService({ search: "eco" }, pagination); + + expect(prisma.banners.count).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { title: { contains: "eco", mode: "insensitive" } }, + { description: { contains: "eco", mode: "insensitive" } }, + ], + }), + }) + ); + }); +}); + +describe("updateAdminBannerService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza NotFoundError cuando no existe el banner", async () => { + prisma.banners.findUnique.mockResolvedValue(null); + + await expect(updateAdminBannerService(1, { title: "Nuevo" })).rejects.toThrow(NotFoundError); + }); + + it("lanza ValidationError cuando endAt es anterior a startAt", async () => { + prisma.banners.findUnique.mockResolvedValue(mockBanner); + + await expect( + updateAdminBannerService(1, { + startAt: "2024-06-10", + endAt: "2024-06-01", + }) + ).rejects.toThrow(ValidationError); + }); +}); + +describe("toggleAdminBannerActiveService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza ValidationError cuando isActive no es boolean", async () => { + await expect(toggleAdminBannerActiveService(1, "si")).rejects.toThrow(ValidationError); + }); + + it("lanza NotFoundError cuando el banner no existe", async () => { + prisma.banners.findUnique.mockResolvedValue(null); + + await expect(toggleAdminBannerActiveService(1, true)).rejects.toThrow(NotFoundError); + }); +}); diff --git a/tests/unit/admin/admin-controllers.test.js b/tests/unit/admin/admin-controllers.test.js index 205e9742..82f16f20 100644 --- a/tests/unit/admin/admin-controllers.test.js +++ b/tests/unit/admin/admin-controllers.test.js @@ -17,6 +17,13 @@ vi.mock("../../../src/modules/admin/users/admin-users.service.js", () => ({ getUsersAdminService: vi.fn(), })); +vi.mock("../../../src/modules/admin/banners/admin-banners.service.js", () => ({ + createAdminBannerService: vi.fn(), + getAdminBannersService: vi.fn(), + toggleAdminBannerActiveService: vi.fn(), + updateAdminBannerService: vi.fn(), +})); + import { approveStore, getPendingStores, @@ -30,6 +37,13 @@ import { import { getUsers as getAdminUsers } from "../../../src/modules/admin/users/admin-users.controller.js"; +import { + createAdminBanner, + getAdminBanners, + toggleAdminBannerActive, + updateAdminBanner, +} from "../../../src/modules/admin/banners/admin-banners.controller.js"; + import { approveStoreService, getPendingStoresService, @@ -43,6 +57,13 @@ import { import { getUsersAdminService } from "../../../src/modules/admin/users/admin-users.service.js"; +import { + createAdminBannerService, + getAdminBannersService, + toggleAdminBannerActiveService, + updateAdminBannerService, +} from "../../../src/modules/admin/banners/admin-banners.service.js"; + const makeCtx = (params = {}, body = {}, query = {}, user = { id_user: 1 }) => { const res = { status: vi.fn().mockReturnThis(), @@ -199,4 +220,78 @@ describe("getAdminUsers", () => { await getAdminUsers(req, res, next); expect(next).toHaveBeenCalled(); }); +}); + +// ─── Admin banners ───────────────────────────────────────────────────────── + +describe("createAdminBanner", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna 201 cuando crea el banner", async () => { + const { req, res, next } = makeCtx({}, { title: "Campaña" }); + createAdminBannerService.mockResolvedValue({ id: 1 }); + await createAdminBanner(req, res, next); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it("llama next cuando el servicio falla", async () => { + const { req, res, next } = makeCtx({}, { title: "Campaña" }); + createAdminBannerService.mockRejectedValue(new Error("fail")); + await createAdminBanner(req, res, next); + expect(next).toHaveBeenCalled(); + }); +}); + +describe("getAdminBanners", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna 200 con la lista de banners", async () => { + const { req, res, next } = makeCtx({}, {}, { search: "eco" }); + getAdminBannersService.mockResolvedValue({ data: [], pagination: {} }); + await getAdminBanners(req, res, next); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("llama next cuando falla", async () => { + const { req, res, next } = makeCtx(); + getAdminBannersService.mockRejectedValue(new Error("fail")); + await getAdminBanners(req, res, next); + expect(next).toHaveBeenCalled(); + }); +}); + +describe("updateAdminBanner", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna 200 al actualizar", async () => { + const { req, res, next } = makeCtx({ id: "1" }, { title: "Nuevo" }); + updateAdminBannerService.mockResolvedValue({ id: 1 }); + await updateAdminBanner(req, res, next); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("llama next cuando falla", async () => { + const { req, res, next } = makeCtx({ id: "1" }, { title: "Nuevo" }); + updateAdminBannerService.mockRejectedValue(new Error("fail")); + await updateAdminBanner(req, res, next); + expect(next).toHaveBeenCalled(); + }); +}); + +describe("toggleAdminBannerActive", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna 200 al activar/desactivar", async () => { + const { req, res, next } = makeCtx({ id: "1" }, { isActive: false }); + toggleAdminBannerActiveService.mockResolvedValue({ id: 1 }); + await toggleAdminBannerActive(req, res, next); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("llama next cuando falla", async () => { + const { req, res, next } = makeCtx({ id: "1" }, { isActive: false }); + toggleAdminBannerActiveService.mockRejectedValue(new Error("fail")); + await toggleAdminBannerActive(req, res, next); + expect(next).toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/tests/unit/banners/banners.controller.test.js b/tests/unit/banners/banners.controller.test.js new file mode 100644 index 00000000..2f9b196e --- /dev/null +++ b/tests/unit/banners/banners.controller.test.js @@ -0,0 +1,37 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +vi.mock("../../../src/modules/global/banners/banners.service.js", () => ({ + getActiveBannersService: vi.fn(), +})); + +import { getActiveBanners } from "../../../src/modules/global/banners/banners.controller.js"; +import { getActiveBannersService } from "../../../src/modules/global/banners/banners.service.js"; + +const makeCtx = (query = {}) => { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + const req = { query }; + const next = vi.fn(); + return { req, res, next }; +}; + +describe("getActiveBanners", () => { + beforeEach(() => vi.clearAllMocks()); + + it("retorna 200 con banners activos", async () => { + const { req, res, next } = makeCtx({ limit: "5" }); + getActiveBannersService.mockResolvedValue([]); + await getActiveBanners(req, res, next); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([]); + }); + + it("llama next cuando falla", async () => { + const { req, res, next } = makeCtx(); + getActiveBannersService.mockRejectedValue(new Error("fail")); + await getActiveBanners(req, res, next); + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/banners/banners.service.test.js b/tests/unit/banners/banners.service.test.js new file mode 100644 index 00000000..4848557d --- /dev/null +++ b/tests/unit/banners/banners.service.test.js @@ -0,0 +1,38 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { prisma } from "../../../src/lib/prisma.js"; +import { getActiveBannersService } from "../../../src/modules/global/banners/banners.service.js"; +import { ValidationError } from "../../../src/lib/errors.js"; + +vi.mock("../../../src/lib/prisma.js", () => ({ + prisma: { + banners: { + findMany: vi.fn(), + }, + }, +})); + +describe("getActiveBannersService", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lanza ValidationError cuando limit es inválido", async () => { + await expect(getActiveBannersService({ limit: "-1" })).rejects.toThrow(ValidationError); + }); + + it("filtra banners activos por rango de fechas", async () => { + prisma.banners.findMany.mockResolvedValue([]); + + await getActiveBannersService({ limit: 5 }); + + expect(prisma.banners.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: true, + is_active: true, + start_at: expect.any(Object), + OR: expect.any(Array), + }), + take: 5, + }) + ); + }); +});