diff --git a/tests/e2e/banners.test.js b/tests/e2e/banners.test.js new file mode 100644 index 0000000..78cf31c --- /dev/null +++ b/tests/e2e/banners.test.js @@ -0,0 +1,397 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import request from "supertest"; +import app from "../../src/app.js"; +import { prisma } from "../../src/lib/prisma.js"; + +vi.mock("../../src/lib/prisma.js", () => ({ + prisma: { + banners: { + create: vi.fn(), + count: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + updateMany: vi.fn(), + }, + }, +})); + +vi.mock("../../src/config/jwt.config.js", () => ({ + default: (req, res, next) => { + const role = req.headers["x-test-role"]; + + if (!role) return res.status(401).json({ errors: { auth: { message: "No autenticado" } } }); + + const users = { + admin: { id_user: 1, role: "ADMIN" }, + seller: { id_user: 10, role: "SELLER" }, + customer: { id_user: 2, role: "CUSTOMER" }, + }; + + req.user = users[role] ?? null; + + if (!req.user) return res.status(401).json({ errors: { auth: { message: "No autenticado" } } }); + + next(); + }, +})); + +const asAdmin = (req) => req.set("x-test-role", "admin"); +const asSeller = (req) => req.set("x-test-role", "seller"); + +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("2025-05-01T10:00:00.000Z"), + end_at: new Date("2025-05-10T10:00:00.000Z"), + is_active: true, + status: true, + created_at: new Date("2025-04-30T10:00:00.000Z"), + updated_at: new Date("2025-04-30T10:00:00.000Z"), +}; + +const validCreateBody = { + title: "Semana Eco", + imageUrl: "https://cdn.example.com/banner.jpg", + startAt: "2025-05-01T10:00:00.000Z", + endAt: "2025-05-10T10:00:00.000Z", + isActive: true, +}; + +// POST /api/admin/banners + +describe("POST /api/admin/banners", () => { + beforeEach(() => vi.clearAllMocks()); + + // OM523 + it("devuelve 401 sin cookie de autenticación", async () => { + const res = await request(app).post("/api/admin/banners").send(validCreateBody); + + expect(res.status).toBe(401); + }); + + it("devuelve 403 cuando el rol no es ADMIN", async () => { + const res = await asSeller( + request(app).post("/api/admin/banners").send(validCreateBody) + ); + + expect(res.status).toBe(403); + }); + + // OM523 + it("devuelve 400 cuando faltan campos requeridos (title, imageUrl o startAt)", async () => { + const res = await asAdmin( + request(app) + .post("/api/admin/banners") + .send({ imageUrl: "https://cdn.example.com/banner.jpg", startAt: "2025-05-01" }) + ); + + expect(res.status).toBe(400); + }); + + it("devuelve 400 cuando endAt es anterior a startAt", async () => { + const res = await asAdmin( + request(app).post("/api/admin/banners").send({ + title: "Campaña", + imageUrl: "https://cdn.example.com/banner.jpg", + startAt: "2025-06-10T00:00:00.000Z", + endAt: "2025-06-01T00:00:00.000Z", + }) + ); + + expect(res.status).toBe(400); + }); + + // OM523 + it("devuelve 201 con el banner creado cuando los datos son válidos", async () => { + prisma.banners.create.mockResolvedValue(mockBanner); + + const res = await asAdmin( + request(app).post("/api/admin/banners").send(validCreateBody) + ); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("id", 1); + expect(res.body).toHaveProperty("title", "Semana Eco"); + expect(res.body).toHaveProperty("imageUrl", "https://cdn.example.com/banner.jpg"); + expect(res.body).toHaveProperty("isActive", true); + expect(res.body).toHaveProperty("status", true); + }); +}); + +// GET /api/admin/banners + +describe("GET /api/admin/banners", () => { + beforeEach(() => vi.clearAllMocks()); + + // OM523 + it("devuelve 401 sin cookie de autenticación", async () => { + const res = await request(app).get("/api/admin/banners"); + + expect(res.status).toBe(401); + }); + + it("devuelve 403 cuando el rol no es ADMIN", async () => { + const res = await asSeller(request(app).get("/api/admin/banners")); + + expect(res.status).toBe(403); + }); + + // OM523 + it("devuelve 200 con estructura paginada correcta", async () => { + prisma.banners.count.mockResolvedValue(1); + prisma.banners.findMany.mockResolvedValue([mockBanner]); + + const res = await asAdmin(request(app).get("/api/admin/banners")); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("data"); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body).toHaveProperty("pagination"); + expect(res.body.pagination).toHaveProperty("total", 1); + expect(res.body.pagination).toHaveProperty("page"); + expect(res.body.pagination).toHaveProperty("limit"); + expect(res.body.pagination).toHaveProperty("totalPages"); + }); + + it("aplica el filtro active=true a la consulta", async () => { + prisma.banners.count.mockResolvedValue(0); + prisma.banners.findMany.mockResolvedValue([]); + + await asAdmin(request(app).get("/api/admin/banners?active=true")); + + expect(prisma.banners.count).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ is_active: true }) }) + ); + }); + + it("aplica el filtro status=false a la consulta", async () => { + prisma.banners.count.mockResolvedValue(0); + prisma.banners.findMany.mockResolvedValue([]); + + await asAdmin(request(app).get("/api/admin/banners?status=false")); + + expect(prisma.banners.count).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ status: false }) }) + ); + }); + + it("aplica búsqueda por título y descripción con el parámetro search", async () => { + prisma.banners.count.mockResolvedValue(0); + prisma.banners.findMany.mockResolvedValue([]); + + await asAdmin(request(app).get("/api/admin/banners?search=eco")); + + expect(prisma.banners.count).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { title: { contains: "eco", mode: "insensitive" } }, + { description: { contains: "eco", mode: "insensitive" } }, + ], + }), + }) + ); + }); +}); + +// PUT /api/admin/banners/:id + +describe("PUT /api/admin/banners/:id", () => { + beforeEach(() => vi.clearAllMocks()); + + // OM523 + it("devuelve 401 sin cookie de autenticación", async () => { + const res = await request(app).put("/api/admin/banners/1").send({ title: "Nuevo" }); + + expect(res.status).toBe(401); + }); + + it("devuelve 403 cuando el rol no es ADMIN", async () => { + const res = await asSeller( + request(app).put("/api/admin/banners/1").send({ title: "Nuevo" }) + ); + + expect(res.status).toBe(403); + }); + + // OM523 + it("devuelve 400 cuando el id no es numérico", async () => { + const res = await asAdmin( + request(app).put("/api/admin/banners/abc").send({ title: "Nuevo" }) + ); + + expect(res.status).toBe(400); + }); + + it("devuelve 404 cuando el banner no existe", async () => { + prisma.banners.findUnique.mockResolvedValue(null); + + const res = await asAdmin( + request(app).put("/api/admin/banners/999").send({ title: "Nuevo" }) + ); + + expect(res.status).toBe(404); + }); + + it("devuelve 400 cuando endAt es anterior a startAt", async () => { + prisma.banners.findUnique.mockResolvedValue(mockBanner); + + const res = await asAdmin( + request(app).put("/api/admin/banners/1").send({ + startAt: "2025-06-10T00:00:00.000Z", + endAt: "2025-06-01T00:00:00.000Z", + }) + ); + + expect(res.status).toBe(400); + }); + + // OM523 + it("devuelve 200 con el banner actualizado", async () => { + const updatedBanner = { ...mockBanner, title: "Nuevo Titulo" }; + prisma.banners.findUnique + .mockResolvedValueOnce(mockBanner) + .mockResolvedValueOnce(updatedBanner); + prisma.banners.updateMany.mockResolvedValue({ count: 1 }); + + const res = await asAdmin( + request(app).put("/api/admin/banners/1").send({ title: "Nuevo Titulo" }) + ); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("id", 1); + expect(res.body).toHaveProperty("title", "Nuevo Titulo"); + }); +}); + +// PATCH /api/admin/banners/:id/active + +describe("PATCH /api/admin/banners/:id/active", () => { + beforeEach(() => vi.clearAllMocks()); + + // OM523 + it("devuelve 401 sin cookie de autenticación", async () => { + const res = await request(app) + .patch("/api/admin/banners/1/active") + .send({ isActive: false }); + + expect(res.status).toBe(401); + }); + + it("devuelve 403 cuando el rol no es ADMIN", async () => { + const res = await asSeller( + request(app).patch("/api/admin/banners/1/active").send({ isActive: false }) + ); + + expect(res.status).toBe(403); + }); + + // OM523 + it("devuelve 400 cuando isActive no es un valor booleano", async () => { + const res = await asAdmin( + request(app).patch("/api/admin/banners/1/active").send({ isActive: "si" }) + ); + + expect(res.status).toBe(400); + }); + + it("devuelve 400 cuando el id no es numérico", async () => { + const res = await asAdmin( + request(app).patch("/api/admin/banners/abc/active").send({ isActive: false }) + ); + + expect(res.status).toBe(400); + }); + + it("devuelve 404 cuando el banner no existe", async () => { + prisma.banners.findUnique.mockResolvedValue(null); + + const res = await asAdmin( + request(app).patch("/api/admin/banners/999/active").send({ isActive: false }) + ); + + expect(res.status).toBe(404); + }); + + // OM523 + it("devuelve 200 al desactivar un banner activo", async () => { + const deactivatedBanner = { ...mockBanner, is_active: false }; + prisma.banners.findUnique + .mockResolvedValueOnce({ id_banner: 1, status: true }) + .mockResolvedValueOnce(deactivatedBanner); + prisma.banners.updateMany.mockResolvedValue({ count: 1 }); + + const res = await asAdmin( + request(app).patch("/api/admin/banners/1/active").send({ isActive: false }) + ); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("isActive", false); + }); + + it("devuelve 200 al activar un banner inactivo", async () => { + const activatedBanner = { ...mockBanner, is_active: true }; + prisma.banners.findUnique + .mockResolvedValueOnce({ id_banner: 1, status: true }) + .mockResolvedValueOnce(activatedBanner); + prisma.banners.updateMany.mockResolvedValue({ count: 1 }); + + const res = await asAdmin( + request(app).patch("/api/admin/banners/1/active").send({ isActive: true }) + ); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("isActive", true); + }); +}); + +// GET /api/banners + +describe("GET /api/banners", () => { + beforeEach(() => vi.clearAllMocks()); + + // OM523 + it("devuelve 200 con un array de banners activos", async () => { + prisma.banners.findMany.mockResolvedValue([mockBanner]); + + const res = await request(app).get("/api/banners"); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body).toHaveLength(1); + expect(res.body[0]).toHaveProperty("id", 1); + expect(res.body[0]).toHaveProperty("title", "Semana Eco"); + expect(res.body[0]).toHaveProperty("isActive", true); + }); + + it("retorna array vacío cuando no hay banners activos", async () => { + prisma.banners.findMany.mockResolvedValue([]); + + const res = await request(app).get("/api/banners"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it("respeta el parámetro limit en la consulta", async () => { + prisma.banners.findMany.mockResolvedValue([mockBanner]); + + const res = await request(app).get("/api/banners?limit=5"); + + expect(res.status).toBe(200); + expect(prisma.banners.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 5 }) + ); + }); + + // OM523 + it("devuelve 400 cuando limit es inválido (negativo, cero o no numérico)", async () => { + for (const limit of ["-1", "0", "abc"]) { + const res = await request(app).get(`/api/banners?limit=${limit}`); + expect(res.status).toBe(400); + } + }); +}); diff --git a/tests/e2e/business-hours.test.js b/tests/e2e/business-hours.test.js new file mode 100644 index 0000000..ef86900 --- /dev/null +++ b/tests/e2e/business-hours.test.js @@ -0,0 +1,221 @@ +import { vi, describe, it, expect, beforeEach, beforeAll } from "vitest"; +import { randomBytes } from "crypto"; +import request from "supertest"; +import jwt from "jsonwebtoken"; +import app from "../../src/app.js"; +import { prisma } from "../../src/lib/prisma.js"; + +vi.mock("../../src/lib/prisma.js", () => ({ + prisma: { + stores: { + findFirst: vi.fn(), + findUnique: vi.fn(), + }, + storeBusinessHours: { + findMany: vi.fn(), + upsert: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +const TEST_JWT_SECRET = randomBytes(32).toString("hex"); +const STORE_ID = 1; +const SELLER_ID = 1; + +let sellerToken; + +beforeAll(() => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + sellerToken = jwt.sign( + { id_user: SELLER_ID, email: "seller@test.com", role: "SELLER" }, + TEST_JWT_SECRET + ); +}); + +const mockStore = { + id_store: STORE_ID, + fk_user: SELLER_ID, + logo: null, + name: "Tienda Test", + status: true, + store_status: "ACTIVE", + user: { id_user: SELLER_ID, status: true }, +}; + +const mockScheduleRows = [ + { + id_store_business_hour: 1, + fk_store: STORE_ID, + day_of_week: 0, + open_time: "08:00", + close_time: "18:00", + is_closed: false, + status: true, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }, +]; + +// ─── OM515-001 Errores en consulta de horarios ──────────────────────────────── + +describe("GET /api/commerces/:id/business-hours", () => { + beforeEach(() => vi.resetAllMocks()); + + it("retorna 400 cuando el ID no es numérico y 404 cuando el comercio no existe", async () => { + const resInvalidId = await request(app).get( + "/api/commerces/abc/business-hours" + ); + expect(resInvalidId.status).toBe(400); + + prisma.stores.findFirst.mockResolvedValue(null); + const resNotFound = await request(app).get( + "/api/commerces/9999/business-hours" + ); + expect(resNotFound.status).toBe(404); + expect(resNotFound.body.message).toMatch(/no encontrado/i); + }); + + // OM515-002 Consulta exitosa de horarios + it("retorna 200 con horarios configurados cuando el comercio existe", async () => { + prisma.stores.findFirst.mockResolvedValue({ id_store: STORE_ID }); + prisma.storeBusinessHours.findMany.mockResolvedValue(mockScheduleRows); + + const res = await request(app).get( + `/api/commerces/${STORE_ID}/business-hours` + ); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.data.schedules)).toBe(true); + expect(res.body.data.schedules).toHaveLength(1); + expect(res.body.data).toHaveProperty("is_open"); + expect(res.body.data).toHaveProperty("open_time"); + expect(res.body.data).toHaveProperty("close_time"); + }); + + it("retorna 200 con schedules vacío cuando el comercio no tiene horarios configurados", async () => { + prisma.stores.findFirst.mockResolvedValue({ id_store: STORE_ID }); + prisma.storeBusinessHours.findMany.mockResolvedValue([]); + + const res = await request(app).get( + `/api/commerces/${STORE_ID}/business-hours` + ); + + expect(res.status).toBe(200); + expect(res.body.data.schedules).toHaveLength(0); + expect(res.body.data.is_open).toBe(false); + }); +}); + +// ─── OM515-003 Autenticación y autorización en actualización ────────────────── + +describe("PUT /api/commerces/:id/business-hours", () => { + beforeEach(() => vi.resetAllMocks()); + + it("retorna 401 sin cookie, 403 cuando no es dueño y 404 cuando el comercio no existe", async () => { + const resUnauth = await request(app) + .put(`/api/commerces/${STORE_ID}/business-hours`) + .send({ schedules: [{ day_of_week: 0, open_time: "08:00", close_time: "18:00", is_closed: false }] }); + expect(resUnauth.status).toBe(401); + + prisma.stores.findUnique.mockResolvedValue({ ...mockStore, fk_user: 99 }); + const resForbidden = await request(app) + .put(`/api/commerces/${STORE_ID}/business-hours`) + .set("Cookie", `userToken=${sellerToken}`) + .send({ schedules: [{ day_of_week: 0, open_time: "08:00", close_time: "18:00", is_closed: false }] }); + expect(resForbidden.status).toBe(403); + + prisma.stores.findUnique.mockResolvedValue(null); + const resNotFound = await request(app) + .put(`/api/commerces/${STORE_ID}/business-hours`) + .set("Cookie", `userToken=${sellerToken}`) + .send({ schedules: [{ day_of_week: 0, open_time: "08:00", close_time: "18:00", is_closed: false }] }); + expect(resNotFound.status).toBe(404); + }); + + // OM515-004 Validaciones de entrada en actualización + it("retorna 400 para distintas entradas inválidas en schedules", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + + const resNoField = await request(app) + .put(`/api/commerces/${STORE_ID}/business-hours`) + .set("Cookie", `userToken=${sellerToken}`) + .send({}); + expect(resNoField.status).toBe(400); + + const resEmpty = await request(app) + .put(`/api/commerces/${STORE_ID}/business-hours`) + .set("Cookie", `userToken=${sellerToken}`) + .send({ schedules: [] }); + expect(resEmpty.status).toBe(400); + + const resInvalidDay = await request(app) + .put(`/api/commerces/${STORE_ID}/business-hours`) + .set("Cookie", `userToken=${sellerToken}`) + .send({ schedules: [{ day_of_week: 7, open_time: "08:00", close_time: "18:00", is_closed: false }] }); + expect(resInvalidDay.status).toBe(400); + + const resInvalidTime = await request(app) + .put(`/api/commerces/${STORE_ID}/business-hours`) + .set("Cookie", `userToken=${sellerToken}`) + .send({ schedules: [{ day_of_week: 0, open_time: "8:0", close_time: "18:00", is_closed: false }] }); + expect(resInvalidTime.status).toBe(400); + + const resInvalidRange = await request(app) + .put(`/api/commerces/${STORE_ID}/business-hours`) + .set("Cookie", `userToken=${sellerToken}`) + .send({ schedules: [{ day_of_week: 0, open_time: "18:00", close_time: "08:00", is_closed: false }] }); + expect(resInvalidRange.status).toBe(400); + + const resDuplicateDay = await request(app) + .put(`/api/commerces/${STORE_ID}/business-hours`) + .set("Cookie", `userToken=${sellerToken}`) + .send({ + schedules: [ + { day_of_week: 0, open_time: "08:00", close_time: "18:00", is_closed: false }, + { day_of_week: 0, open_time: "09:00", close_time: "17:00", is_closed: false }, + ], + }); + expect(resDuplicateDay.status).toBe(400); + }); + + // OM515-005 Actualización exitosa de horarios + it("retorna 200 al actualizar días abiertos y días cerrados", async () => { + prisma.stores.findUnique.mockResolvedValue(mockStore); + prisma.storeBusinessHours.upsert.mockResolvedValue({}); + prisma.$transaction.mockResolvedValue([]); + prisma.storeBusinessHours.findMany.mockResolvedValue([ + ...mockScheduleRows, + { + id_store_business_hour: 2, + fk_store: STORE_ID, + day_of_week: 6, + open_time: null, + close_time: null, + is_closed: true, + status: true, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }, + ]); + + const res = await request(app) + .put(`/api/commerces/${STORE_ID}/business-hours`) + .set("Cookie", `userToken=${sellerToken}`) + .send({ + schedules: [ + { day_of_week: 0, open_time: "08:00", close_time: "18:00", is_closed: false }, + { day_of_week: 6, is_closed: true }, + ], + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toMatch(/actualizados/i); + expect(Array.isArray(res.body.data.schedules)).toBe(true); + expect(res.body.data.schedules).toHaveLength(2); + const closedDay = res.body.data.schedules.find((s) => s.day_of_week === 6); + expect(closedDay.is_closed).toBe(true); + }); +}); diff --git a/tests/e2e/stock.test.js b/tests/e2e/stock.test.js index cfd5c9f..1427bda 100644 --- a/tests/e2e/stock.test.js +++ b/tests/e2e/stock.test.js @@ -10,7 +10,7 @@ vi.mock("../../src/lib/prisma.js", () => ({ update: vi.fn(), count: vi.fn() }, - carts: { findFirst: vi.fn(), upsert: vi.fn(), update: vi.fn(), findUnique: vi.fn() }, + carts: { findFirst: vi.fn(), upsert: vi.fn(), update: vi.fn(), findUnique: vi.fn(), findMany: vi.fn() }, cartItems: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), findMany: vi.fn() }, orders: { create: vi.fn(), findUnique: vi.fn() }, orderItems: { createMany: vi.fn(), findMany: vi.fn() }, @@ -134,6 +134,59 @@ describe("POST /api/users/:customerId/cart/items", () => { expect(res.body).toHaveProperty("error.message"); expect(String(res.body.error.message).toLowerCase()).toMatch(/stock|insuficiente/); }); + + it("Retorna 201 y la respuesta incluye stock en cada item del carrito", async () => { + prisma.$transaction.mockImplementation(async (fn) => + fn({ + products: { + findFirst: vi.fn().mockResolvedValue({ + id_product: 20, + fk_store: 2, + quantity: 10, + status: true, + visible: true, + store: { id_store: 2, store_status: "ACTIVE", status: true } + }) + }, + carts: { upsert: vi.fn().mockResolvedValue({ id_cart: 5 }) }, + cartItems: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({}) + } + }) + ); + + prisma.carts.findUnique.mockResolvedValue({ + id_cart: 5, + fk_store: 2, + cart_status: "ACTIVE", + store: { id_store: 2, name: "Tienda Test", logo: null }, + items: [{ + id_cart_item: 1, + quantity: 1, + product: { + id_product: 20, + name: "Producto Test", + price: 500, + offer_price: null, + is_offer: false, + image_url: null, + quantity: 10 + } + }] + }); + + const res = await request(app) + .post("/api/users/2/cart/items") + .set("Cookie", customerCookie) + .send({ productId: 20, quantity: 1 }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("items"); + expect(res.body.items[0].product).toHaveProperty("stock"); + expect(typeof res.body.items[0].product.stock).toBe("number"); + expect(res.body.items[0].product.stock).toBe(10); + }); }); //PUT /api/users/cart/items/:cartItemId @@ -274,4 +327,127 @@ describe("POST /api/orders", () => { expect(res.body).toHaveProperty("error.message"); expect(String(res.body.error.message).toLowerCase()).toMatch(/stock|insuficiente/); }); +}); + +// GET /api/users/:customerId/carts + +describe("GET /api/users/:customerId/carts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("Retorna 200 con campo stock en items: positivo para producto con stock y cero para producto agotado", async () => { + prisma.carts.findMany.mockResolvedValue([{ id_cart: 1 }]); + + prisma.carts.findUnique.mockResolvedValue({ + id_cart: 1, + fk_store: 10, + cart_status: "ACTIVE", + store: { id_store: 10, name: "Tienda Test", logo: null }, + items: [ + { + id_cart_item: 1, + quantity: 2, + product: { + id_product: 1, + name: "Producto con stock", + price: 100, + offer_price: null, + is_offer: false, + image_url: null, + quantity: 5 + } + }, + { + id_cart_item: 2, + quantity: 1, + product: { + id_product: 2, + name: "Producto sin stock", + price: 200, + offer_price: null, + is_offer: false, + image_url: null, + quantity: 0 + } + } + ] + }); + + const res = await request(app) + .get("/api/users/2/carts") + .set("Cookie", customerCookie); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("carts"); + expect(res.body.carts).toHaveLength(1); + + const items = res.body.carts[0].items; + expect(items).toHaveLength(2); + + const itemConStock = items.find((i) => i.product.id === 1); + expect(itemConStock.product).toHaveProperty("stock"); + expect(itemConStock.product.stock).toBe(5); + + const itemSinStock = items.find((i) => i.product.id === 2); + expect(itemSinStock.product).toHaveProperty("stock"); + expect(itemSinStock.product.stock).toBe(0); + }); +}); + +// GET /api/users/cart/:cartId/items + +describe("GET /api/users/cart/:cartId/items", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("Retorna 200 con campo stock en items: positivo para producto con stock y cero para producto agotado", async () => { + prisma.carts.findFirst.mockResolvedValue({ id_cart: 5 }); + + prisma.cartItems.findMany.mockResolvedValue([ + { + id_cart_item: 10, + quantity: 3, + product: { + id_product: 3, + name: "Producto con stock", + price: 150, + offer_price: null, + is_offer: false, + quantity: 8, + store: { name: "Tienda B" } + } + }, + { + id_cart_item: 11, + quantity: 1, + product: { + id_product: 4, + name: "Producto sin stock", + price: 300, + offer_price: null, + is_offer: false, + quantity: 0, + store: { name: "Tienda B" } + } + } + ]); + + const res = await request(app) + .get("/api/users/cart/5/items") + .set("Cookie", customerCookie); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body).toHaveLength(2); + + const itemConStock = res.body.find((i) => i.product.id === 3); + expect(itemConStock.product).toHaveProperty("stock"); + expect(itemConStock.product.stock).toBe(8); + + const itemSinStock = res.body.find((i) => i.product.id === 4); + expect(itemSinStock.product).toHaveProperty("stock"); + expect(itemSinStock.product.stock).toBe(0); + }); }); \ No newline at end of file diff --git a/tests/e2e/store-approval-flow.test.js b/tests/e2e/store-approval-flow.test.js index 185e85c..0c0c483 100644 --- a/tests/e2e/store-approval-flow.test.js +++ b/tests/e2e/store-approval-flow.test.js @@ -280,3 +280,116 @@ describe("flujo e2e de aprobacion de comercio con rechazo y reenvio", () => { ]); }); }); + +// Validaciones de autorización y estado en el rechazo + +describe("validaciones de autorizacion y estado en el endpoint de rechazo", () => { + beforeEach(() => { + vi.clearAllMocks(); + store = buildStore(); + notifications = []; + nextNotificationId = 1; + setupStatefulPrisma(); + }); + + it("retorna 400 cuando el comercio ya esta SUSPENDED", async () => { + store = buildStore({ store_status: "SUSPENDED" }); + + const res = await request(app) + .patch(`/api/admin/stores/${STORE_ID}/reject`) + .set("Cookie", `userToken=${adminToken}`) + .send({ reason: "Motivo valido" }); + + expect(res.status).toBe(400); + expect(notifications).toHaveLength(0); + }); + + it("retorna 403 cuando el usuario no tiene rol ADMIN", async () => { + const res = await request(app) + .patch(`/api/admin/stores/${STORE_ID}/reject`) + .set("Cookie", `userToken=${sellerToken}`) + .send({ reason: "Motivo valido" }); + + expect(res.status).toBe(403); + expect(notifications).toHaveLength(0); + }); +}); + +//Contenido y acceso a notificaciones de rechazo + +describe("contenido y acceso a notificaciones de rechazo", () => { + beforeEach(() => { + vi.clearAllMocks(); + store = buildStore(); + notifications = []; + nextNotificationId = 1; + setupStatefulPrisma(); + }); + + it("la notificacion de rechazo incluye el motivo en el campo message", async () => { + const reason = "Falta documentacion comercial"; + + await request(app) + .patch(`/api/admin/stores/${STORE_ID}/reject`) + .set("Cookie", `userToken=${adminToken}`) + .send({ reason }); + + const res = await request(app) + .get("/api/notifications") + .set("Cookie", `userToken=${sellerToken}`); + + expect(res.status).toBe(200); + expect(res.body.notifications[0].message).toContain(reason); + }); + + it("retorna 401 al acceder a notificaciones sin autenticacion", async () => { + const res = await request(app).get("/api/notifications"); + + expect(res.status).toBe(401); + }); +}); + +// Visibilidad de productos durante el flujo de aprobacion + +describe("visibilidad de productos durante el flujo de aprobacion", () => { + beforeEach(() => { + vi.clearAllMocks(); + store = buildStore({ store_status: "SUSPENDED" }); + notifications = []; + nextNotificationId = 1; + setupStatefulPrisma(); + }); + + it("oculta productos al reenviar y los hace visibles al aprobar", async () => { + // PUT sobre comercio SUSPENDED → productos quedan visible: false + const updateRes = await request(app) + .put(`/api/commerces/${STORE_ID}`) + .set("Cookie", `userToken=${sellerToken}`) + .send({ name: "Comercio Actualizado" }); + + expect(updateRes.status).toBe(200); + expect(prisma.products.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ fk_store: STORE_ID, status: true }), + data: { visible: false }, + }) + ); + + // El mock stateful actualizó store.store_status a INACTIVE; limpiar historial + // para que la siguiente asercion solo observe la llamada del approve + prisma.products.updateMany.mockClear(); + + // PATCH approve → productos quedan visible: true + const approveRes = await request(app) + .patch(`/api/admin/stores/${STORE_ID}/approve`) + .set("Cookie", `userToken=${adminToken}`); + + expect(approveRes.status).toBe(200); + expect(prisma.products.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ fk_store: STORE_ID, status: true }), + data: { visible: true }, + }) + ); + }); +}); diff --git a/tests/e2e/users.test.js b/tests/e2e/users.test.js index 2ae1896..ba1a798 100644 --- a/tests/e2e/users.test.js +++ b/tests/e2e/users.test.js @@ -8,8 +8,10 @@ vi.mock("../../src/lib/prisma.js", () => ({ prisma: { users: { findUnique: vi.fn(), + findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), + updateMany: vi.fn(), }, addresses: { findFirst: vi.fn(), @@ -27,7 +29,12 @@ vi.mock("bcrypt", () => ({ }, })); +vi.mock("../../src/lib/email.service.js", () => ({ + sendPasswordResetEmail: vi.fn(), +})); + import bcrypt from "bcrypt"; +import { sendPasswordResetEmail } from "../../src/lib/email.service.js"; const TEST_JWT_SECRET = "test-secret-users"; @@ -67,7 +74,7 @@ const mockUpdatedUser = { addresses: [], }; -// ─── POST /api/users/register ───────────────────────────────────────────────── +// POST /api/users/register describe("POST /api/users/register", () => { beforeEach(() => vi.resetAllMocks()); @@ -132,7 +139,7 @@ describe("POST /api/users/register", () => { }); }); -// ─── PUT /api/users/:id_user ────────────────────────────────────────────────── +// PUT /api/users/:id_user describe("PUT /api/users/:id_user", () => { beforeEach(() => vi.resetAllMocks()); @@ -187,7 +194,7 @@ describe("PUT /api/users/:id_user", () => { }); }); -// ─── PUT /api/users/:id_user/password ──────────────────────────────────────── +//PUT /api/users/:id_user/password describe("PUT /api/users/:id_user/password", () => { beforeEach(() => vi.resetAllMocks()); @@ -250,7 +257,7 @@ describe("PUT /api/users/:id_user/password", () => { }); }); -// ─── PUT /api/users/:id_user/addresses/:id_address ─────────────────────────── +// PUT /api/users/:id_user/addresses/:id_address describe("PUT /api/users/:id_user/addresses/:id_address", () => { beforeEach(() => vi.resetAllMocks()); @@ -317,3 +324,164 @@ describe("PUT /api/users/:id_user/addresses/:id_address", () => { expect(res.body.data).toHaveProperty("city", "Luque"); }); }); + +// POST /api/users/forgot-password + +describe("POST /api/users/forgot-password", () => { + beforeEach(() => vi.resetAllMocks()); + + //Sin email o email con formato inválido + it("devuelve 400 cuando no se envía el campo email", async () => { + const res = await request(app) + .post("/api/users/forgot-password") + .send({}); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("devuelve 400 cuando el email tiene formato inválido", async () => { + const res = await request(app) + .post("/api/users/forgot-password") + .send({ email: "no-es-un-email" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + //Respuesta genérica independiente del registro + it("devuelve 200 con mensaje genérico y no envía correo cuando el email no está registrado", async () => { + prisma.users.findUnique.mockResolvedValue(null); + + const res = await request(app) + .post("/api/users/forgot-password") + .send({ email: "noexiste@test.com" }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe("Si el correo está registrado, recibirás un enlace para restablecer tu contraseña."); + expect(sendPasswordResetEmail).not.toHaveBeenCalled(); + }); + + it("devuelve 200 con mensaje genérico y envía correo cuando el usuario existe y está activo", async () => { + prisma.users.findUnique.mockResolvedValue({ id_user: 1, status: true }); + prisma.users.update.mockResolvedValue({}); + sendPasswordResetEmail.mockResolvedValue(undefined); + + const res = await request(app) + .post("/api/users/forgot-password") + .send({ email: "usuario@test.com" }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe("Si el correo está registrado, recibirás un enlace para restablecer tu contraseña."); + expect(sendPasswordResetEmail).toHaveBeenCalledWith( + "usuario@test.com", + expect.any(String) + ); + }); +}); + +// POST /api/users/validate-reset-token + +describe("POST /api/users/validate-reset-token", () => { + beforeEach(() => vi.resetAllMocks()); + + // Token ausente o inválido/expirado + it("devuelve 400 cuando no se envía el token", async () => { + const res = await request(app) + .post("/api/users/validate-reset-token") + .send({}); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("devuelve 400 cuando el token es inválido o ya expiró", async () => { + prisma.users.findFirst.mockResolvedValue(null); + + const res = await request(app) + .post("/api/users/validate-reset-token") + .send({ token: "tokeninvalido" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/no es válido|expirado/i); + }); + + // Token válido y vigente + it("devuelve 200 con valid: true cuando el token es válido y vigente", async () => { + prisma.users.findFirst.mockResolvedValue({ id_user: 1 }); + + const res = await request(app) + .post("/api/users/validate-reset-token") + .send({ token: "tokenvalido123abc" }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.valid).toBe(true); + }); +}); + +// POST /api/users/reset-password + +describe("POST /api/users/reset-password", () => { + beforeEach(() => vi.resetAllMocks()); + + // Validaciones de entrada + it("devuelve 400 cuando no se envía el token", async () => { + const res = await request(app) + .post("/api/users/reset-password") + .send({ newPassword: "nuevaPass123" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("devuelve 400 cuando no se envía newPassword", async () => { + const res = await request(app) + .post("/api/users/reset-password") + .send({ token: "tokenvalido" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it("devuelve 400 cuando newPassword tiene menos de 8 caracteres", async () => { + const res = await request(app) + .post("/api/users/reset-password") + .send({ token: "tokenvalido", newPassword: "corta" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/8 caracteres/i); + }); + + //Token inválido o expirado + it("devuelve 400 cuando el token es inválido o ya expiró", async () => { + bcrypt.hash.mockResolvedValue("hashed"); + prisma.users.updateMany.mockResolvedValue({ count: 0 }); + + const res = await request(app) + .post("/api/users/reset-password") + .send({ token: "tokeninvalido", newPassword: "nuevaPass123" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.message).toMatch(/no es válido|expirado/i); + }); + + // Restablecimiento exitoso + it("devuelve 200 cuando la contraseña se restablece exitosamente", async () => { + bcrypt.hash.mockResolvedValue("$2b$10$newhash"); + prisma.users.updateMany.mockResolvedValue({ count: 1 }); + + const res = await request(app) + .post("/api/users/reset-password") + .send({ token: "tokenvalido123abc", newPassword: "nuevaPass123" }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toMatch(/restablecida/i); + }); +});